mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-09 07:48:15 +00:00
Compare commits
36 Commits
v1.46.10
...
feat/affil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2df4f5b7f | ||
|
|
243cdc510a | ||
|
|
a82fd03b37 | ||
|
|
a5444a82e0 | ||
|
|
5cbef3ab63 | ||
|
|
5ba39e4f0f | ||
|
|
2c523e4d05 | ||
|
|
368ace0532 | ||
|
|
24bb802e6c | ||
|
|
b3577de3e3 | ||
|
|
6b1b7bd97b | ||
|
|
14ed2b72d2 | ||
|
|
48f036e4e9 | ||
|
|
dd860aa808 | ||
|
|
f95caaa9c0 | ||
|
|
0c31ff8424 | ||
|
|
35223d0e4f | ||
|
|
747004e7de | ||
|
|
19fc06dee7 | ||
|
|
a3ed76d715 | ||
|
|
5ba424d174 | ||
|
|
29654b4f36 | ||
|
|
72f9fbcf43 | ||
|
|
0764b5b32c | ||
|
|
2bd536459f | ||
|
|
08edf69c2b | ||
|
|
f30db3f357 | ||
|
|
17f4953edf | ||
|
|
ac0c2271cf | ||
|
|
ceb3c29728 | ||
|
|
e683a1339f | ||
|
|
209116bfb8 | ||
|
|
93e503b3cf | ||
|
|
7ccafa0213 | ||
|
|
a2fe28ebb3 | ||
|
|
3c43e57be0 |
@@ -1,38 +1,18 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import {
|
||||
AFFILIATE_FAQ_COUNT,
|
||||
AFFILIATE_FAQ_PREFIX
|
||||
} from '../src/components/affiliates/affiliateFaqs'
|
||||
import { programDetailRows } from '../src/components/affiliates/programDetails'
|
||||
import type { TranslationKey } from '../src/i18n/translations'
|
||||
import { affiliateFaqs } from '../src/data/affiliateFaq'
|
||||
import { t } from '../src/i18n/translations'
|
||||
import { test } from './fixtures/blockExternalMedia'
|
||||
|
||||
const FIRST_FAQ_QUESTION = t(
|
||||
`${AFFILIATE_FAQ_PREFIX}.1.q` as TranslationKey,
|
||||
'en'
|
||||
)
|
||||
const FIRST_FAQ_ANSWER = t(
|
||||
`${AFFILIATE_FAQ_PREFIX}.1.a` as TranslationKey,
|
||||
'en'
|
||||
)
|
||||
const FIRST_PROGRAM_DETAIL_LABEL = t(programDetailRows[0].labelKey, 'en')
|
||||
const FIRST_PROGRAM_DETAIL_VALUE = t(programDetailRows[0].valueKey, 'en')
|
||||
const PROGRAM_DETAIL_TABLE_ROW_COUNT = programDetailRows.length + 1
|
||||
|
||||
const PATH = '/affiliates'
|
||||
const APPLY_URL = 'https://forms.gle/RS8L2ttcuGap4Q1v6'
|
||||
|
||||
const SECTION_TESTIDS = [
|
||||
'affiliate-hero',
|
||||
'affiliate-trust-band',
|
||||
'affiliate-how-it-works',
|
||||
'affiliate-audience',
|
||||
'affiliate-program-details',
|
||||
'affiliate-brand-assets',
|
||||
'affiliate-footer-cta'
|
||||
] as const
|
||||
const TERMS_PATH = '/affiliates/terms'
|
||||
const FAQ_COUNT = affiliateFaqs.length
|
||||
const FIRST_FAQ = affiliateFaqs[0]
|
||||
const HERO_HEADING_TEXT = `${t('affiliate.hero.headingHighlight', 'en')} ${t('affiliate.hero.headingMuted', 'en')}`
|
||||
const CTA_HEADING_TEXT = t('affiliate.cta.heading', 'en')
|
||||
const CTA_APPLY_LABEL = t('affiliate.cta.apply', 'en')
|
||||
const CTA_TERMS_LABEL = t('affiliate.cta.termsLabel', 'en')
|
||||
|
||||
test.describe('Affiliates landing — desktop @smoke', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
@@ -41,32 +21,38 @@ test.describe('Affiliates landing — desktop @smoke', () => {
|
||||
|
||||
test('renders the hero heading and is indexable', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Become a Comfy Partner', level: 1 })
|
||||
page.getByRole('heading', { level: 1, name: HERO_HEADING_TEXT })
|
||||
).toBeVisible()
|
||||
|
||||
await expect(page.locator('meta[name="robots"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('renders every page section in top-to-bottom order', async ({
|
||||
page
|
||||
}) => {
|
||||
const ys: number[] = []
|
||||
for (const id of SECTION_TESTIDS) {
|
||||
const section = page.getByTestId(id)
|
||||
await expect(section).toBeVisible()
|
||||
const box = await section.boundingBox()
|
||||
expect(box, `${id} bounding box`).not.toBeNull()
|
||||
ys.push(box!.y)
|
||||
}
|
||||
const sortedYs = [...ys].sort((a, b) => a - b)
|
||||
expect(ys).toEqual(sortedYs)
|
||||
test('renders the closing CTA heading and apply button', async ({ page }) => {
|
||||
const ctaSection = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { level: 2, name: CTA_HEADING_TEXT })
|
||||
})
|
||||
const ctaHeading = ctaSection.getByRole('heading', {
|
||||
level: 2,
|
||||
name: CTA_HEADING_TEXT
|
||||
})
|
||||
await ctaHeading.scrollIntoViewIfNeeded()
|
||||
await expect(ctaHeading).toBeVisible()
|
||||
|
||||
const applyButton = ctaSection.getByRole('link', { name: CTA_APPLY_LABEL })
|
||||
await expect(applyButton).toBeVisible()
|
||||
await expect(applyButton).toHaveAttribute('href', APPLY_URL)
|
||||
await expect(applyButton).toHaveAttribute('target', '_blank')
|
||||
await expect(applyButton).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
test('renders the program details table on desktop', async ({ page }) => {
|
||||
const table = page.getByTestId('affiliate-program-details-table')
|
||||
await expect(table).toBeVisible()
|
||||
const rows = table.getByRole('row')
|
||||
await expect(rows).toHaveCount(PROGRAM_DETAIL_TABLE_ROW_COUNT)
|
||||
test('CTA section links to the affiliate terms page in the same tab', async ({
|
||||
page
|
||||
}) => {
|
||||
const termsLink = page.getByRole('link', { name: CTA_TERMS_LABEL })
|
||||
await termsLink.scrollIntoViewIfNeeded()
|
||||
await expect(termsLink).toBeVisible()
|
||||
await expect(termsLink).toHaveAttribute('href', TERMS_PATH)
|
||||
await expect(termsLink).not.toHaveAttribute('target', '_blank')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,42 +79,21 @@ test.describe('Affiliates landing — desktop interactions', () => {
|
||||
const parsed = JSON.parse(faqJsonLd!)
|
||||
expect(parsed['@type']).toBe('FAQPage')
|
||||
expect(Array.isArray(parsed.mainEntity)).toBe(true)
|
||||
expect(parsed.mainEntity.length).toBe(AFFILIATE_FAQ_COUNT)
|
||||
})
|
||||
|
||||
test('hero and footer CTAs target the application form in a new tab', async ({
|
||||
page
|
||||
}) => {
|
||||
const heroCta = page.getByTestId('affiliate-hero-cta')
|
||||
await expect(heroCta).toBeVisible()
|
||||
await expect(heroCta).toHaveAttribute('href', APPLY_URL)
|
||||
await expect(heroCta).toHaveAttribute('target', '_blank')
|
||||
await expect(heroCta).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
|
||||
const footerCta = page.getByTestId('affiliate-footer-cta-button')
|
||||
await expect(footerCta).toHaveAttribute('href', APPLY_URL)
|
||||
await expect(footerCta).toHaveAttribute('target', '_blank')
|
||||
await expect(footerCta).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
test('footer links to the affiliate terms page as a same-tab navigation', async ({
|
||||
page
|
||||
}) => {
|
||||
const link = page
|
||||
.getByTestId('affiliate-footer-cta')
|
||||
.getByRole('link', { name: /Read the affiliate program terms/i })
|
||||
await expect(link).toBeVisible()
|
||||
await expect(link).toBeEnabled()
|
||||
await expect(link).toHaveAttribute('href', '/affiliates/terms')
|
||||
await expect(link).not.toHaveAttribute('target', '_blank')
|
||||
expect(parsed.mainEntity.length).toBe(FAQ_COUNT)
|
||||
})
|
||||
|
||||
test('Apply Now CTA opens the application form in a new tab', async ({
|
||||
page,
|
||||
context
|
||||
}) => {
|
||||
const ctaSection = page.locator('section').filter({
|
||||
has: page.getByRole('heading', { level: 2, name: CTA_HEADING_TEXT })
|
||||
})
|
||||
const applyButton = ctaSection.getByRole('link', { name: CTA_APPLY_LABEL })
|
||||
await applyButton.scrollIntoViewIfNeeded()
|
||||
|
||||
const popupPromise = context.waitForEvent('page')
|
||||
await page.getByTestId('affiliate-hero-cta').click()
|
||||
await applyButton.click()
|
||||
const popup = await popupPromise
|
||||
await popup.waitForLoadState('domcontentloaded')
|
||||
const popupUrl = popup.url()
|
||||
@@ -140,12 +105,15 @@ test.describe('Affiliates landing — desktop interactions', () => {
|
||||
})
|
||||
|
||||
test('FAQ items toggle open and closed on click', async ({ page }) => {
|
||||
const firstQuestion = page.getByRole('button', { name: FIRST_FAQ_QUESTION })
|
||||
const firstQuestion = page.getByRole('button', {
|
||||
name: FIRST_FAQ.question.en
|
||||
})
|
||||
await firstQuestion.scrollIntoViewIfNeeded()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(page.getByText(FIRST_FAQ_ANSWER)).toBeVisible()
|
||||
await expect(page.getByText(FIRST_FAQ.answer.en)).toBeVisible()
|
||||
|
||||
await firstQuestion.click()
|
||||
await expect(firstQuestion).toHaveAttribute('aria-expanded', 'false')
|
||||
@@ -157,44 +125,24 @@ test.describe('Affiliates landing — mobile @mobile', () => {
|
||||
await page.goto(PATH)
|
||||
})
|
||||
|
||||
test('renders the hero heading and primary CTA at narrow viewports', async ({
|
||||
page
|
||||
}) => {
|
||||
test('renders the hero heading at narrow viewports', async ({ page }) => {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Become a Comfy Partner', level: 1 })
|
||||
).toBeVisible()
|
||||
await expect(page.getByTestId('affiliate-hero-cta')).toBeVisible()
|
||||
})
|
||||
|
||||
test('program details collapse to a stacked definition list', async ({
|
||||
page
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByTestId('affiliate-program-details-table')
|
||||
).toBeHidden()
|
||||
const detailsList = page
|
||||
.getByTestId('affiliate-program-details')
|
||||
.locator('dl')
|
||||
await expect(detailsList).toBeVisible()
|
||||
await expect(
|
||||
detailsList.getByText(FIRST_PROGRAM_DETAIL_LABEL)
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
detailsList.getByText(FIRST_PROGRAM_DETAIL_VALUE)
|
||||
page.getByRole('heading', { level: 1, name: HERO_HEADING_TEXT })
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test('all major sections remain visible without horizontal overflow', async ({
|
||||
page
|
||||
}) => {
|
||||
for (const id of SECTION_TESTIDS) {
|
||||
const section = page.getByTestId(id)
|
||||
await expect(section).toBeVisible()
|
||||
const box = await section.boundingBox()
|
||||
expect(box, `${id} bounding box`).not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(
|
||||
page.viewportSize()!.width + 1
|
||||
)
|
||||
}
|
||||
test('closing CTA stays within the viewport width', async ({ page }) => {
|
||||
const ctaHeading = page.getByRole('heading', {
|
||||
level: 2,
|
||||
name: CTA_HEADING_TEXT
|
||||
})
|
||||
await ctaHeading.scrollIntoViewIfNeeded()
|
||||
await expect(ctaHeading).toBeVisible()
|
||||
|
||||
const box = await ctaHeading.boundingBox()
|
||||
expect(box, 'CTA heading bounding box').not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(
|
||||
page.viewportSize()!.width + 1
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
BIN
apps/website/public/affiliates/brand/comfy-amplified-logo.png
Normal file
BIN
apps/website/public/affiliates/brand/comfy-amplified-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5 11.5811L10.2582 18.0581L20 6.05811" stroke="#F2FF59" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 234 B |
@@ -1,42 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const audienceKeys: TranslationKey[] = [
|
||||
'affiliate-landing.audience.item.0',
|
||||
'affiliate-landing.audience.item.1',
|
||||
'affiliate-landing.audience.item.2',
|
||||
'affiliate-landing.audience.item.3',
|
||||
'affiliate-landing.audience.item.4'
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-6 py-20 md:px-20 md:py-28"
|
||||
data-testid="affiliate-audience"
|
||||
>
|
||||
<SectionHeader>
|
||||
{{ t('affiliate-landing.audience.heading', locale) }}
|
||||
</SectionHeader>
|
||||
<ul class="mx-auto mt-12 flex max-w-3xl flex-col gap-4">
|
||||
<li
|
||||
v-for="key in audienceKeys"
|
||||
:key="key"
|
||||
class="text-primary-comfy-canvas flex items-start gap-4 text-base md:text-lg"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-1 inline-flex size-6 shrink-0 items-center justify-center rounded-full text-xs font-bold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
<span>{{ t(key, locale) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,62 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
import { brandAssets } from './brandAssets'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-6 py-20 md:px-20 md:py-28"
|
||||
data-testid="affiliate-brand-assets"
|
||||
>
|
||||
<SectionHeader>
|
||||
{{ t('affiliate-landing.assets.heading', locale) }}
|
||||
<template #subtitle>
|
||||
<p
|
||||
class="text-primary-comfy-canvas/70 mx-auto mt-4 max-w-2xl text-base"
|
||||
>
|
||||
{{ t('affiliate-landing.assets.subheading', locale) }}
|
||||
</p>
|
||||
</template>
|
||||
</SectionHeader>
|
||||
<ul
|
||||
class="mx-auto mt-12 grid max-w-6xl grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<li
|
||||
v-for="asset in brandAssets"
|
||||
:key="asset.id"
|
||||
class="bg-transparency-white-t4 border-primary-comfy-canvas/10 flex flex-col overflow-hidden rounded-4xl border"
|
||||
:data-testid="`affiliate-asset-${asset.id}`"
|
||||
>
|
||||
<div
|
||||
class="bg-primary-comfy-ink/40 flex aspect-video items-center justify-center overflow-hidden p-6"
|
||||
>
|
||||
<img
|
||||
:src="asset.preview"
|
||||
:alt="t(asset.titleKey, locale)"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2 p-5">
|
||||
<h3 class="text-primary-comfy-canvas text-base font-light">
|
||||
{{ t(asset.titleKey, locale) }}
|
||||
</h3>
|
||||
<a
|
||||
:href="asset.download"
|
||||
:download="asset.download.split('/').pop()"
|
||||
class="text-primary-comfy-yellow mt-auto inline-flex items-center gap-1 text-sm font-bold tracking-wider uppercase hover:underline"
|
||||
>
|
||||
{{ t('affiliate-landing.assets.downloadLabel', locale) }}
|
||||
<span aria-hidden="true">↓</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const routes = getRoutes(locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="bg-secondary-mauve/30 border-primary-comfy-canvas/10 mt-12 border-t px-6 py-20 text-center md:py-28"
|
||||
data-testid="affiliate-footer-cta"
|
||||
>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-3xl font-light md:text-4xl lg:text-5xl"
|
||||
>
|
||||
{{ t('affiliate-landing.footerCta.heading', locale) }}
|
||||
</h2>
|
||||
<div class="mt-8 flex flex-col items-center gap-4">
|
||||
<BrandButton
|
||||
:href="externalLinks.affiliateApplicationForm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="lg"
|
||||
:aria-label="t('affiliate-landing.cta.applyAriaLabel', locale)"
|
||||
data-testid="affiliate-footer-cta-button"
|
||||
class="px-8 py-4 text-base"
|
||||
>
|
||||
{{ t('affiliate-landing.cta.apply', locale) }}
|
||||
</BrandButton>
|
||||
<a
|
||||
:href="routes.affiliateTerms"
|
||||
class="text-primary-comfy-canvas/70 text-sm underline-offset-4 hover:underline"
|
||||
>
|
||||
{{ t('affiliate-landing.footerCta.termsLink', locale) }}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,84 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const highlightKeys: TranslationKey[] = [
|
||||
'affiliate-landing.hero.highlight.0',
|
||||
'affiliate-landing.hero.highlight.1',
|
||||
'affiliate-landing.hero.highlight.2',
|
||||
'affiliate-landing.hero.highlight.3'
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col items-stretch gap-10 px-6 pt-12 pb-16 lg:flex-row lg:items-center lg:gap-16 lg:px-20 lg:pt-20 lg:pb-24"
|
||||
data-testid="affiliate-hero"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<h1
|
||||
class="text-primary-comfy-canvas text-4xl/tight font-light md:text-5xl/tight lg:text-6xl/tight"
|
||||
>
|
||||
{{ t('affiliate-landing.hero.heading', locale) }}
|
||||
</h1>
|
||||
<p
|
||||
class="text-primary-comfy-yellow mt-4 text-2xl font-light md:text-3xl lg:text-4xl"
|
||||
>
|
||||
{{ t('affiliate-landing.hero.subheading', locale) }}
|
||||
</p>
|
||||
<p class="text-primary-comfy-canvas/80 mt-6 max-w-xl text-base">
|
||||
{{ t('affiliate-landing.hero.body', locale) }}
|
||||
</p>
|
||||
<ul class="mt-6 flex flex-col gap-3">
|
||||
<li
|
||||
v-for="key in highlightKeys"
|
||||
:key="key"
|
||||
class="text-primary-comfy-canvas flex items-start gap-3 text-base"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow text-primary-comfy-ink mt-1 inline-flex size-5 shrink-0 items-center justify-center rounded-full text-xs font-bold"
|
||||
aria-hidden="true"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
<span>{{ t(key, locale) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-8">
|
||||
<BrandButton
|
||||
:href="externalLinks.affiliateApplicationForm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="lg"
|
||||
:aria-label="t('affiliate-landing.cta.applyAriaLabel', locale)"
|
||||
data-testid="affiliate-hero-cta"
|
||||
class="px-8 py-4 text-base"
|
||||
>
|
||||
{{ t('affiliate-landing.cta.apply', locale) }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-1 items-center justify-center"
|
||||
data-testid="affiliate-hero-media"
|
||||
>
|
||||
<slot name="media">
|
||||
<video
|
||||
src="https://media.comfy.org/website/homepage/showcase/ui-overview.webm"
|
||||
autoplay
|
||||
loop
|
||||
muted
|
||||
playsinline
|
||||
aria-hidden="true"
|
||||
class="w-full max-w-xl rounded-4xl"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,55 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const steps: { titleKey: TranslationKey; bodyKey: TranslationKey }[] = [
|
||||
{
|
||||
titleKey: 'affiliate-landing.how.step.0.title',
|
||||
bodyKey: 'affiliate-landing.how.step.0.body'
|
||||
},
|
||||
{
|
||||
titleKey: 'affiliate-landing.how.step.1.title',
|
||||
bodyKey: 'affiliate-landing.how.step.1.body'
|
||||
},
|
||||
{
|
||||
titleKey: 'affiliate-landing.how.step.2.title',
|
||||
bodyKey: 'affiliate-landing.how.step.2.body'
|
||||
}
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-6 py-20 md:px-20 md:py-28"
|
||||
data-testid="affiliate-how-it-works"
|
||||
>
|
||||
<SectionHeader>
|
||||
{{ t('affiliate-landing.how.heading', locale) }}
|
||||
</SectionHeader>
|
||||
<ol
|
||||
class="mx-auto mt-12 grid max-w-5xl grid-cols-1 gap-6 md:grid-cols-3 md:gap-8"
|
||||
>
|
||||
<li
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.titleKey"
|
||||
class="bg-transparency-white-t4 border-primary-comfy-canvas/10 flex flex-col gap-4 rounded-4xl border p-8"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-yellow text-sm font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<h3 class="text-primary-comfy-canvas text-2xl font-light">
|
||||
{{ t(step.titleKey, locale) }}
|
||||
</h3>
|
||||
<p class="text-primary-comfy-canvas/70 text-sm">
|
||||
{{ t(step.bodyKey, locale) }}
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,79 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import SectionHeader from '../common/SectionHeader.vue'
|
||||
import { programDetailRows } from './programDetails'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="px-6 py-20 md:px-20 md:py-28"
|
||||
data-testid="affiliate-program-details"
|
||||
>
|
||||
<SectionHeader>
|
||||
{{ t('affiliate-landing.details.heading', locale) }}
|
||||
</SectionHeader>
|
||||
<div class="mx-auto mt-12 max-w-3xl">
|
||||
<div class="hidden md:block">
|
||||
<table
|
||||
class="w-full border-collapse text-left"
|
||||
data-testid="affiliate-program-details-table"
|
||||
>
|
||||
<thead>
|
||||
<tr class="border-primary-comfy-canvas/20 border-b">
|
||||
<th
|
||||
scope="col"
|
||||
class="text-primary-comfy-yellow py-4 pr-6 text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('affiliate-landing.details.headerLabel', locale) }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="text-primary-comfy-yellow py-4 text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t('affiliate-landing.details.headerValue', locale) }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="row in programDetailRows"
|
||||
:key="row.labelKey"
|
||||
class="border-primary-comfy-canvas/10 border-b"
|
||||
>
|
||||
<th
|
||||
scope="row"
|
||||
class="text-primary-comfy-canvas py-5 pr-6 text-base font-light"
|
||||
>
|
||||
{{ t(row.labelKey, locale) }}
|
||||
</th>
|
||||
<td class="text-primary-comfy-canvas/80 py-5 text-base">
|
||||
{{ t(row.valueKey, locale) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<dl class="flex flex-col gap-6 md:hidden">
|
||||
<div
|
||||
v-for="row in programDetailRows"
|
||||
:key="row.labelKey"
|
||||
class="border-primary-comfy-canvas/10 flex flex-col gap-1 border-b pb-4"
|
||||
>
|
||||
<dt
|
||||
class="text-primary-comfy-yellow text-xs font-bold tracking-widest uppercase"
|
||||
>
|
||||
{{ t(row.labelKey, locale) }}
|
||||
</dt>
|
||||
<dd class="text-primary-comfy-canvas text-base">
|
||||
{{ t(row.valueKey, locale) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="border-primary-comfy-canvas/10 border-y px-6 py-10 text-center md:py-12"
|
||||
data-testid="affiliate-trust-band"
|
||||
>
|
||||
<p
|
||||
class="text-primary-comfy-canvas text-sm tracking-wider uppercase md:text-base"
|
||||
>
|
||||
{{ t('affiliate-landing.trust.label', locale) }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { TranslationKey } from '../../i18n/translations'
|
||||
|
||||
export const AFFILIATE_FAQ_PREFIX = 'affiliate-landing.faq'
|
||||
export const AFFILIATE_FAQ_HEADING_KEY: TranslationKey =
|
||||
'affiliate-landing.faq.heading'
|
||||
export const AFFILIATE_FAQ_COUNT = 8
|
||||
@@ -1,154 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { hasKey, t, translationKeys } from '../../i18n/translations'
|
||||
import {
|
||||
AFFILIATE_FAQ_COUNT,
|
||||
AFFILIATE_FAQ_HEADING_KEY,
|
||||
AFFILIATE_FAQ_PREFIX
|
||||
} from './affiliateFaqs'
|
||||
import { brandAssets } from './brandAssets'
|
||||
|
||||
const PREFIX = 'affiliate-landing'
|
||||
|
||||
const EXPECTED_SECTION_PREFIXES = [
|
||||
'page',
|
||||
'cta',
|
||||
'hero',
|
||||
'trust',
|
||||
'how',
|
||||
'audience',
|
||||
'details',
|
||||
'assets',
|
||||
'faq',
|
||||
'footerCta'
|
||||
] as const
|
||||
|
||||
const HERO_HIGHLIGHT_COUNT = 4
|
||||
const HOW_STEP_COUNT = 3
|
||||
const AUDIENCE_ITEM_COUNT = 5
|
||||
const DETAILS_ROW_COUNT = 6
|
||||
|
||||
const INTERNAL_KEY_PATTERNS = [
|
||||
/open-questions/,
|
||||
/todo/i,
|
||||
/draft/i,
|
||||
/placeholder/i,
|
||||
/internal/i
|
||||
]
|
||||
|
||||
function affiliateKeys(): string[] {
|
||||
return translationKeys.filter((k) => k.startsWith(`${PREFIX}.`))
|
||||
}
|
||||
|
||||
describe('affiliate landing i18n', () => {
|
||||
it('exposes the canonical top-level section prefixes', () => {
|
||||
const keys = affiliateKeys()
|
||||
for (const section of EXPECTED_SECTION_PREFIXES) {
|
||||
const hit = keys.some((k) => k.startsWith(`${PREFIX}.${section}.`))
|
||||
expect(hit, `missing section: ${section}`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('orders sections as the page renders them', () => {
|
||||
const keys = affiliateKeys()
|
||||
const seenSections: string[] = []
|
||||
for (const key of keys) {
|
||||
const section = key.split('.')[1]
|
||||
if (!section) continue
|
||||
if (!seenSections.includes(section)) seenSections.push(section)
|
||||
}
|
||||
const orderedExpected = EXPECTED_SECTION_PREFIXES.filter((s) =>
|
||||
seenSections.includes(s)
|
||||
)
|
||||
const orderedActual = seenSections.filter((s) =>
|
||||
(EXPECTED_SECTION_PREFIXES as readonly string[]).includes(s)
|
||||
)
|
||||
expect(orderedActual).toEqual([...orderedExpected])
|
||||
})
|
||||
|
||||
it('exposes hero, page, and cta keys editors will need', () => {
|
||||
expect(hasKey(`${PREFIX}.page.title`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.page.description`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.cta.apply`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.cta.applyAriaLabel`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.hero.heading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.hero.subheading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.hero.body`)).toBe(true)
|
||||
for (let i = 0; i < HERO_HIGHLIGHT_COUNT; i++) {
|
||||
expect(hasKey(`${PREFIX}.hero.highlight.${i}`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the trust band, how-it-works, and audience copy', () => {
|
||||
expect(hasKey(`${PREFIX}.trust.label`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.how.heading`)).toBe(true)
|
||||
for (let i = 0; i < HOW_STEP_COUNT; i++) {
|
||||
expect(hasKey(`${PREFIX}.how.step.${i}.title`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.how.step.${i}.body`)).toBe(true)
|
||||
}
|
||||
expect(hasKey(`${PREFIX}.audience.heading`)).toBe(true)
|
||||
for (let i = 0; i < AUDIENCE_ITEM_COUNT; i++) {
|
||||
expect(hasKey(`${PREFIX}.audience.item.${i}`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes the program details rows', () => {
|
||||
expect(hasKey(`${PREFIX}.details.heading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.details.headerLabel`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.details.headerValue`)).toBe(true)
|
||||
for (let i = 0; i < DETAILS_ROW_COUNT; i++) {
|
||||
expect(hasKey(`${PREFIX}.details.row.${i}.label`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.details.row.${i}.value`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('matches every brand-asset tile to a translation key', () => {
|
||||
expect(hasKey(`${PREFIX}.assets.heading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.assets.subheading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.assets.downloadLabel`)).toBe(true)
|
||||
for (const asset of brandAssets) {
|
||||
expect(hasKey(asset.titleKey)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes every 1-indexed faq.<n>.q/a pair from 1 to AFFILIATE_FAQ_COUNT (FAQSection contract)', () => {
|
||||
expect(AFFILIATE_FAQ_PREFIX).toBe(`${PREFIX}.faq`)
|
||||
expect(hasKey(AFFILIATE_FAQ_HEADING_KEY)).toBe(true)
|
||||
for (let n = 1; n <= AFFILIATE_FAQ_COUNT; n++) {
|
||||
expect(hasKey(`${AFFILIATE_FAQ_PREFIX}.${n}.q`)).toBe(true)
|
||||
expect(hasKey(`${AFFILIATE_FAQ_PREFIX}.${n}.a`)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps AFFILIATE_FAQ_COUNT in sync with the actual faq.<n>.q keys in translations', () => {
|
||||
const faqQuestionKeyPattern = new RegExp(
|
||||
`^${AFFILIATE_FAQ_PREFIX}\\.(\\d+)\\.q$`
|
||||
)
|
||||
const indices = translationKeys
|
||||
.map((k) => k.match(faqQuestionKeyPattern)?.[1])
|
||||
.filter((m): m is string => m !== undefined)
|
||||
.map((s) => parseInt(s, 10))
|
||||
.sort((a, b) => a - b)
|
||||
expect(indices).toEqual(
|
||||
Array.from({ length: AFFILIATE_FAQ_COUNT }, (_, i) => i + 1)
|
||||
)
|
||||
})
|
||||
|
||||
it('exposes the footer cta copy', () => {
|
||||
expect(hasKey(`${PREFIX}.footerCta.heading`)).toBe(true)
|
||||
expect(hasKey(`${PREFIX}.footerCta.termsLink`)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns non-empty english copy for every affiliate-landing key', () => {
|
||||
for (const key of affiliateKeys()) {
|
||||
expect(t(key as never, 'en').trim().length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not leak internal-only keys (drafts, todos, open questions)', () => {
|
||||
const leaks = affiliateKeys().filter((k) =>
|
||||
INTERNAL_KEY_PATTERNS.some((re) => re.test(k))
|
||||
)
|
||||
expect(leaks).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,59 +0,0 @@
|
||||
import type { TranslationKey } from '../../i18n/translations'
|
||||
|
||||
interface BrandAsset {
|
||||
id: string
|
||||
titleKey: TranslationKey
|
||||
download: string
|
||||
preview: string
|
||||
}
|
||||
|
||||
export const brandAssets: BrandAsset[] = [
|
||||
{
|
||||
id: 'logo-horizontal',
|
||||
titleKey: 'affiliate-landing.assets.tile.logo-horizontal.title',
|
||||
download: '/icons/logo.svg',
|
||||
preview: '/icons/logo.svg'
|
||||
},
|
||||
{
|
||||
id: 'logomark',
|
||||
titleKey: 'affiliate-landing.assets.tile.logomark.title',
|
||||
download: '/icons/logomark.svg',
|
||||
preview: '/icons/logomark.svg'
|
||||
},
|
||||
{
|
||||
id: 'comfy-full-logo-yellow',
|
||||
titleKey: 'affiliate-landing.assets.tile.comfy-full-logo-yellow.title',
|
||||
download: '/affiliates/brand/comfy-full-logo-yellow.svg',
|
||||
preview: '/affiliates/brand/comfy-full-logo-yellow.svg'
|
||||
},
|
||||
{
|
||||
id: 'comfy-full-logo-ink',
|
||||
titleKey: 'affiliate-landing.assets.tile.comfy-full-logo-ink.title',
|
||||
download: '/affiliates/brand/comfy-full-logo-ink.svg',
|
||||
preview: '/affiliates/brand/comfy-full-logo-ink.svg'
|
||||
},
|
||||
{
|
||||
id: 'amplified-logo-mark',
|
||||
titleKey: 'affiliate-landing.assets.tile.amplified-logo-mark.title',
|
||||
download: '/affiliates/brand/comfy-amplified-logo-mark.svg',
|
||||
preview: '/affiliates/brand/comfy-amplified-logo-mark.svg'
|
||||
},
|
||||
{
|
||||
id: 'dimensional-logo-mark',
|
||||
titleKey: 'affiliate-landing.assets.tile.dimensional-logo-mark.title',
|
||||
download: '/affiliates/brand/comfy-dimensional-logo-mark.svg',
|
||||
preview: '/affiliates/brand/comfy-dimensional-logo-mark.svg'
|
||||
},
|
||||
{
|
||||
id: 'color-combo-yellow',
|
||||
titleKey: 'affiliate-landing.assets.tile.color-combo-yellow.title',
|
||||
download: '/affiliates/brand/comfy-color-combo-yellow.svg',
|
||||
preview: '/affiliates/brand/comfy-color-combo-yellow.svg'
|
||||
},
|
||||
{
|
||||
id: 'color-combo-ink',
|
||||
titleKey: 'affiliate-landing.assets.tile.color-combo-ink.title',
|
||||
download: '/affiliates/brand/comfy-color-combo-ink.svg',
|
||||
preview: '/affiliates/brand/comfy-color-combo-ink.svg'
|
||||
}
|
||||
]
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { TranslationKey } from '../../i18n/translations'
|
||||
|
||||
interface ProgramDetailRow {
|
||||
labelKey: TranslationKey
|
||||
valueKey: TranslationKey
|
||||
}
|
||||
|
||||
export const programDetailRows: ProgramDetailRow[] = [
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.0.label',
|
||||
valueKey: 'affiliate-landing.details.row.0.value'
|
||||
},
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.1.label',
|
||||
valueKey: 'affiliate-landing.details.row.1.value'
|
||||
},
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.2.label',
|
||||
valueKey: 'affiliate-landing.details.row.2.value'
|
||||
},
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.3.label',
|
||||
valueKey: 'affiliate-landing.details.row.3.value'
|
||||
},
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.4.label',
|
||||
valueKey: 'affiliate-landing.details.row.4.value'
|
||||
},
|
||||
{
|
||||
labelKey: 'affiliate-landing.details.row.5.label',
|
||||
valueKey: 'affiliate-landing.details.row.5.value'
|
||||
}
|
||||
]
|
||||
60
apps/website/src/components/blocks/BenefitsGrid01.vue
Normal file
60
apps/website/src/components/blocks/BenefitsGrid01.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import GlassCard from '../common/GlassCard.vue'
|
||||
|
||||
type Benefit = { id: string; description: string }
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank' | '_self' | '_parent' | '_top'
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
benefits: readonly Benefit[]
|
||||
primaryCta?: Cta
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="mb-12 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<GlassCard class="mx-auto max-w-7xl">
|
||||
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4">
|
||||
<article
|
||||
v-for="(benefit, index) in benefits"
|
||||
:key="benefit.id"
|
||||
class="flex flex-col gap-6 rounded-4xl bg-primary-comfy-ink p-6 lg:p-8"
|
||||
>
|
||||
<span
|
||||
class="text-primary-comfy-yellow font-mono text-sm font-bold tracking-wide"
|
||||
>
|
||||
{{ String(index + 1).padStart(2, '0') }}
|
||||
</span>
|
||||
<p
|
||||
class="text-base/relaxed font-medium text-primary-comfy-canvas lg:text-xl"
|
||||
>
|
||||
{{ benefit.description }}
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<div v-if="primaryCta" class="mt-10 flex justify-center lg:mt-12">
|
||||
<BrandButton
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
size="lg"
|
||||
class="px-20 py-4 text-base uppercase"
|
||||
variant="outline"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
65
apps/website/src/components/blocks/BrandAssetsGrid01.vue
Normal file
65
apps/website/src/components/blocks/BrandAssetsGrid01.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
type Asset = {
|
||||
id: string
|
||||
title: string
|
||||
download: string
|
||||
preview: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
subheading: string
|
||||
downloadLabel: string
|
||||
assets: readonly Asset[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<div class="mx-auto max-w-6xl text-center">
|
||||
<h2
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
<p class="mx-auto mt-4 max-w-2xl text-base text-primary-comfy-canvas/70">
|
||||
{{ subheading }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
class="mx-auto mt-12 grid max-w-6xl grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<li
|
||||
v-for="asset in assets"
|
||||
:key="asset.id"
|
||||
class="bg-transparency-white-t4 flex flex-col overflow-hidden rounded-4xl border border-primary-comfy-canvas/10"
|
||||
>
|
||||
<div
|
||||
class="flex aspect-video items-center justify-center overflow-hidden bg-primary-comfy-ink/40 p-6"
|
||||
>
|
||||
<img
|
||||
:src="asset.preview"
|
||||
:alt="asset.title"
|
||||
class="max-h-full max-w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2 p-5">
|
||||
<h3 class="text-base font-light text-primary-comfy-canvas">
|
||||
{{ asset.title }}
|
||||
</h3>
|
||||
<a
|
||||
:href="asset.download"
|
||||
:download="asset.download.split('/').pop()"
|
||||
class="text-primary-comfy-yellow mt-auto inline-flex items-center gap-1 text-sm font-bold tracking-wider uppercase hover:underline"
|
||||
>
|
||||
{{ downloadLabel }}
|
||||
<span aria-hidden="true">↓</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
56
apps/website/src/components/blocks/ChecklistSplit01.vue
Normal file
56
apps/website/src/components/blocks/ChecklistSplit01.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import GlassCard from '../common/GlassCard.vue'
|
||||
import CheckIcon from '../icons/CheckIcon.vue'
|
||||
|
||||
type Criterion = { id: string; label: string }
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
subheading: string
|
||||
eyebrow?: string
|
||||
criteria: readonly Criterion[]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="mb-12 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<GlassCard class="px-6 py-10 lg:px-16 lg:py-14">
|
||||
<div
|
||||
class="grid grid-cols-1 items-center gap-10 lg:grid-cols-2 lg:gap-16"
|
||||
>
|
||||
<h3 class="text-2xl font-light text-primary-comfy-canvas lg:text-4xl">
|
||||
{{ subheading }}
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<span
|
||||
v-if="eyebrow"
|
||||
class="text-xs font-bold tracking-widest text-primary-comfy-canvas uppercase"
|
||||
>
|
||||
{{ eyebrow }}
|
||||
</span>
|
||||
<ul class="flex flex-col gap-4">
|
||||
<li
|
||||
v-for="criterion in criteria"
|
||||
:key="criterion.id"
|
||||
class="flex items-start gap-3"
|
||||
>
|
||||
<CheckIcon
|
||||
class="text-primary-comfy-yellow mt-0.5 size-5 shrink-0"
|
||||
/>
|
||||
<span class="text-sm text-primary-comfy-canvas lg:text-base">
|
||||
{{ criterion.label }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</section>
|
||||
</template>
|
||||
50
apps/website/src/components/blocks/CtaCenter01.vue
Normal file
50
apps/website/src/components/blocks/CtaCenter01.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank' | '_self' | '_parent' | '_top'
|
||||
}
|
||||
|
||||
type TermsLink = {
|
||||
label: string
|
||||
href: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
primaryCta: Cta
|
||||
termsLink: TermsLink
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="max-w-9xl mx-auto flex flex-col items-center px-6 py-16 text-center lg:py-24"
|
||||
>
|
||||
<h2
|
||||
class="text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<BrandButton
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
:rel="primaryCta.target === '_blank' ? 'noopener noreferrer' : undefined"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="mt-10 px-20 py-4 text-base uppercase lg:mt-12"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</BrandButton>
|
||||
|
||||
<a
|
||||
:href="termsLink.href"
|
||||
class="mt-8 text-sm text-primary-comfy-canvas/70 underline underline-offset-4 transition-colors hover:text-primary-comfy-canvas"
|
||||
>
|
||||
{{ termsLink.label }}
|
||||
</a>
|
||||
</section>
|
||||
</template>
|
||||
94
apps/website/src/components/blocks/FAQSplit01.vue
Normal file
94
apps/website/src/components/blocks/FAQSplit01.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { reactive, watch } from 'vue'
|
||||
|
||||
type Faq = { id: string; question: string; answer: string }
|
||||
|
||||
const { faqs } = defineProps<{
|
||||
heading: string
|
||||
faqs: readonly Faq[]
|
||||
}>()
|
||||
|
||||
const expanded = reactive<boolean[]>(faqs.map(() => false))
|
||||
|
||||
watch(
|
||||
() => faqs.length,
|
||||
(length) => {
|
||||
if (length === expanded.length) return
|
||||
expanded.length = 0
|
||||
for (let i = 0; i < length; i += 1) expanded.push(false)
|
||||
}
|
||||
)
|
||||
|
||||
function toggle(index: number) {
|
||||
expanded[index] = !expanded[index]
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-4 py-24 md:px-20 md:py-40">
|
||||
<div class="flex flex-col gap-6 md:flex-row md:gap-16">
|
||||
<!-- Left heading -->
|
||||
<div
|
||||
class="bg-primary-comfy-ink sticky top-20 z-10 w-full shrink-0 self-start py-4 md:top-28 md:w-80 md:py-0"
|
||||
>
|
||||
<h2 class="text-primary-comfy-canvas text-4xl font-light md:text-5xl">
|
||||
{{ heading }}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Right FAQ list -->
|
||||
<div class="flex-1">
|
||||
<div
|
||||
v-for="(faq, index) in faqs"
|
||||
:key="faq.id"
|
||||
class="border-primary-comfy-canvas/20 border-b"
|
||||
>
|
||||
<button
|
||||
:id="`faq-trigger-${faq.id}`"
|
||||
type="button"
|
||||
:aria-expanded="expanded[index]"
|
||||
:aria-controls="`faq-panel-${faq.id}`"
|
||||
:class="
|
||||
cn(
|
||||
'flex w-full cursor-pointer items-center justify-between text-left',
|
||||
index === 0 ? 'pb-6' : 'py-6'
|
||||
)
|
||||
"
|
||||
@click="toggle(index)"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'text-lg font-light md:text-xl',
|
||||
expanded[index]
|
||||
? 'text-primary-comfy-yellow'
|
||||
: 'text-primary-comfy-canvas'
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ faq.question }}
|
||||
</span>
|
||||
<span
|
||||
class="text-primary-comfy-yellow ml-4 shrink-0 text-2xl"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ expanded[index] ? '−' : '+' }}
|
||||
</span>
|
||||
</button>
|
||||
<section
|
||||
v-show="expanded[index]"
|
||||
:id="`faq-panel-${faq.id}`"
|
||||
role="region"
|
||||
:aria-labelledby="`faq-trigger-${faq.id}`"
|
||||
class="pb-6"
|
||||
>
|
||||
<p class="text-primary-comfy-canvas/70 text-sm whitespace-pre-line">
|
||||
{{ faq.answer }}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
153
apps/website/src/components/blocks/HeroSplit01.vue
Normal file
153
apps/website/src/components/blocks/HeroSplit01.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import BrandButton from '../common/BrandButton.vue'
|
||||
import ProductHeroBadge from '../common/ProductHeroBadge.vue'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
import CheckIcon from '../icons/CheckIcon.vue'
|
||||
|
||||
type Cta = {
|
||||
label: string
|
||||
href: string
|
||||
target?: '_blank' | '_self' | '_parent' | '_top'
|
||||
}
|
||||
|
||||
type VideoTrack = {
|
||||
src: string
|
||||
kind: 'subtitles' | 'captions' | 'descriptions'
|
||||
srclang: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const {
|
||||
locale = 'en',
|
||||
badgeText,
|
||||
badgeLogoSrc,
|
||||
badgeLogoAlt,
|
||||
title,
|
||||
titleHighlight,
|
||||
features = [],
|
||||
primaryCta,
|
||||
secondaryCta,
|
||||
imageSrc,
|
||||
imageAlt = '',
|
||||
imageWidth = 800,
|
||||
imageHeight = 600,
|
||||
imagePosition = 'right',
|
||||
videoSrc,
|
||||
videoPoster,
|
||||
videoTracks = [],
|
||||
videoAutoplay = false,
|
||||
videoLoop = false,
|
||||
videoMinimal = false,
|
||||
videoHideControls = false
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
badgeText: string
|
||||
badgeLogoSrc?: string
|
||||
badgeLogoAlt?: string
|
||||
title: string
|
||||
titleHighlight?: string
|
||||
features?: string[]
|
||||
primaryCta: Cta
|
||||
secondaryCta?: Cta
|
||||
imageSrc?: string
|
||||
imageAlt?: string
|
||||
imageWidth?: number
|
||||
imageHeight?: number
|
||||
imagePosition?: 'left' | 'right'
|
||||
videoSrc?: string
|
||||
videoPoster?: string
|
||||
videoTracks?: VideoTrack[]
|
||||
videoAutoplay?: boolean
|
||||
videoLoop?: boolean
|
||||
videoMinimal?: boolean
|
||||
videoHideControls?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
:class="
|
||||
cn(
|
||||
'max-w-9xl relative mx-auto flex flex-col items-center gap-12 px-6 pt-20 pb-16 md:pt-28 md:pb-24 lg:items-center lg:gap-16 lg:px-16',
|
||||
imagePosition === 'right' ? 'lg:flex-row' : 'lg:flex-row-reverse'
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="w-full lg:flex-1">
|
||||
<ProductHeroBadge
|
||||
:text="badgeText"
|
||||
:logo-src="badgeLogoSrc"
|
||||
:logo-alt="badgeLogoAlt"
|
||||
/>
|
||||
|
||||
<h1
|
||||
class="mt-8 text-2xl leading-[125%] font-light tracking-[-1.44px] text-primary-comfy-canvas md:text-4xl lg:text-5xl"
|
||||
>
|
||||
<template v-if="titleHighlight">
|
||||
<span class="text-primary-warm-white">{{ titleHighlight }}</span>
|
||||
{{ title }}
|
||||
</template>
|
||||
<template v-else>{{ title }}</template>
|
||||
</h1>
|
||||
|
||||
<ul v-if="features.length" class="mt-8 space-y-3">
|
||||
<li
|
||||
v-for="feature in features"
|
||||
:key="feature"
|
||||
class="flex items-start gap-3 text-base text-primary-comfy-canvas"
|
||||
>
|
||||
<CheckIcon class="text-primary-comfy-yellow mt-1 size-5 shrink-0" />
|
||||
{{ feature }}
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-10 flex flex-col gap-4 sm:flex-row">
|
||||
<BrandButton
|
||||
:href="primaryCta.href"
|
||||
:target="primaryCta.target"
|
||||
size="lg"
|
||||
class="px-8 py-4 text-base uppercase"
|
||||
>
|
||||
{{ primaryCta.label }}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
v-if="secondaryCta"
|
||||
:href="secondaryCta.href"
|
||||
:target="secondaryCta.target"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="px-8 py-4 text-base uppercase"
|
||||
>
|
||||
{{ secondaryCta.label }}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="order-first w-full lg:order-last lg:flex-1">
|
||||
<VideoPlayer
|
||||
v-if="videoSrc"
|
||||
:locale
|
||||
:src="videoSrc"
|
||||
:poster="videoPoster"
|
||||
:tracks="videoTracks"
|
||||
:autoplay="videoAutoplay"
|
||||
:loop="videoLoop"
|
||||
:minimal="videoMinimal"
|
||||
:hide-controls="videoHideControls"
|
||||
/>
|
||||
<img
|
||||
v-else-if="imageSrc"
|
||||
:src="imageSrc"
|
||||
:alt="imageAlt"
|
||||
:width="imageWidth"
|
||||
:height="imageHeight"
|
||||
fetchpriority="high"
|
||||
decoding="async"
|
||||
class="aspect-4/3 w-full rounded-3xl object-cover"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
91
apps/website/src/components/blocks/StepsGrid01.vue
Normal file
91
apps/website/src/components/blocks/StepsGrid01.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import NodeUnionIcon from '../icons/NodeUnionIcon.vue'
|
||||
|
||||
type Step = { id: string; label: string; description: string }
|
||||
|
||||
defineProps<{
|
||||
heading: string
|
||||
steps: readonly Step[]
|
||||
}>()
|
||||
|
||||
const isRtlRow = (i: number) => Math.floor(i / 2) % 2 === 1
|
||||
const isFullSpan = (i: number, total: number) =>
|
||||
i === total - 1 && total % 2 === 1
|
||||
|
||||
function hasHorizontalConnector(i: number, total: number) {
|
||||
if (isFullSpan(i, total)) return false
|
||||
if (!isRtlRow(i) && i % 2 === 0 && i + 1 < total) return true
|
||||
if (isRtlRow(i) && i % 2 === 1) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function hasMobileVertical(i: number, total: number) {
|
||||
return i < total - 1
|
||||
}
|
||||
|
||||
function hasLgVertical(i: number, total: number) {
|
||||
return i % 2 === 1 && i + 1 < total
|
||||
}
|
||||
|
||||
function cardClass(i: number, total: number) {
|
||||
const fullSpan = isFullSpan(i, total)
|
||||
const rtl = isRtlRow(i)
|
||||
return cn(
|
||||
'border-primary-comfy-yellow relative rounded-3xl border-2 p-8 lg:p-10',
|
||||
fullSpan && 'lg:col-span-2',
|
||||
!fullSpan && rtl && i % 2 === 0 && 'lg:col-start-2',
|
||||
!fullSpan && rtl && i % 2 === 1 && 'lg:col-start-1'
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="mb-12 text-center text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
|
||||
>
|
||||
{{ heading }}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
class="mx-auto grid max-w-3xl grid-cols-1 gap-4 lg:grid-flow-dense lg:grid-cols-2"
|
||||
>
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.id"
|
||||
:class="cardClass(index, steps.length)"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow font-formula-narrow inline-block -skew-x-12 rounded-sm px-3 py-1.5 text-sm font-bold tracking-wide text-primary-comfy-ink uppercase lg:text-base"
|
||||
>
|
||||
<span class="inline-block skew-x-12">
|
||||
{{ index + 1 }}. {{ step.label }}
|
||||
</span>
|
||||
</span>
|
||||
<p class="mt-6 text-sm/relaxed text-primary-comfy-canvas lg:text-base">
|
||||
{{ step.description }}
|
||||
</p>
|
||||
|
||||
<NodeUnionIcon
|
||||
v-if="hasHorizontalConnector(index, steps.length)"
|
||||
class="text-primary-comfy-yellow absolute top-1/2 right-0 hidden size-4 translate-x-[calc(100%+2px)] -translate-y-1/2 scale-x-150 rotate-90 lg:block"
|
||||
/>
|
||||
<NodeUnionIcon
|
||||
v-if="
|
||||
hasMobileVertical(index, steps.length) ||
|
||||
hasLgVertical(index, steps.length)
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'text-primary-comfy-yellow absolute bottom-0 left-1/2 size-4 -translate-x-1/2 translate-y-[calc(100%+2px)] scale-x-150',
|
||||
!hasMobileVertical(index, steps.length) && 'hidden lg:block',
|
||||
!hasLgVertical(index, steps.length) && 'lg:hidden'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
|
||||
import WireNodeLayout from '../common/WireNodeLayout.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const reasons = [
|
||||
const reasons: TranslationKey[] = [
|
||||
'careers.whyJoin.reason1',
|
||||
'careers.whyJoin.reason2',
|
||||
'careers.whyJoin.reason3',
|
||||
'careers.whyJoin.reason4',
|
||||
'careers.whyJoin.reason5'
|
||||
] as const
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import type { BrandButtonVariants } from './brandButton.variants'
|
||||
import { brandButtonVariants } from './brandButton.variants'
|
||||
|
||||
const {
|
||||
href,
|
||||
target,
|
||||
rel,
|
||||
variant,
|
||||
size,
|
||||
class: customClass = ''
|
||||
} = defineProps<{
|
||||
const props = defineProps<{
|
||||
href?: string
|
||||
target?: string
|
||||
rel?: string
|
||||
@@ -21,15 +15,25 @@ const {
|
||||
size?: BrandButtonVariants['size']
|
||||
class?: HTMLAttributes['class']
|
||||
}>()
|
||||
|
||||
const resolvedRel = computed(
|
||||
() =>
|
||||
props.rel ?? (props.target === '_blank' ? 'noopener noreferrer' : undefined)
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="href ? 'a' : 'button'"
|
||||
:href
|
||||
:target
|
||||
:rel
|
||||
:class="cn(brandButtonVariants({ variant, size }), customClass)"
|
||||
:is="props.href ? 'a' : 'button'"
|
||||
:href="props.href"
|
||||
:target="props.target"
|
||||
:rel="resolvedRel"
|
||||
:class="
|
||||
cn(
|
||||
brandButtonVariants({ variant: props.variant, size: props.size }),
|
||||
props.class ?? ''
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="ppformula-text-center">
|
||||
<slot />
|
||||
|
||||
@@ -28,14 +28,18 @@ const {
|
||||
poster,
|
||||
tracks = [],
|
||||
autoplay = false,
|
||||
minimal = false
|
||||
loop = false,
|
||||
minimal = false,
|
||||
hideControls = false
|
||||
} = defineProps<{
|
||||
locale?: Locale
|
||||
src?: string
|
||||
poster?: string
|
||||
tracks?: VideoTrack[]
|
||||
autoplay?: boolean
|
||||
loop?: boolean
|
||||
minimal?: boolean
|
||||
hideControls?: boolean
|
||||
}>()
|
||||
|
||||
const playerEl = useTemplateRef<HTMLDivElement>('playerEl')
|
||||
@@ -200,8 +204,9 @@ function toggleFullscreen() {
|
||||
crossorigin="anonymous"
|
||||
playsinline
|
||||
:autoplay
|
||||
:loop
|
||||
muted
|
||||
@click="playing = !playing"
|
||||
@click="hideControls ? undefined : (playing = !playing)"
|
||||
>
|
||||
<track
|
||||
v-for="track in tracks"
|
||||
@@ -215,7 +220,7 @@ function toggleFullscreen() {
|
||||
|
||||
<!-- Minimal centered play/pause button -->
|
||||
<div
|
||||
v-if="minimal && src"
|
||||
v-if="minimal && src && !hideControls"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-0 flex items-center justify-center transition-opacity duration-300',
|
||||
@@ -235,7 +240,7 @@ function toggleFullscreen() {
|
||||
|
||||
<!-- Bottom control bar -->
|
||||
<div
|
||||
v-if="src && !minimal"
|
||||
v-if="src && !minimal && !hideControls"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-x-0 bottom-0 flex items-center gap-3 p-4 transition-opacity duration-300 lg:px-6 lg:py-5',
|
||||
@@ -285,7 +290,7 @@ function toggleFullscreen() {
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<svg
|
||||
class="text-primary-comfy-ink size-3.5 lg:size-4"
|
||||
class="size-3.5 text-primary-comfy-ink lg:size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -331,7 +336,7 @@ function toggleFullscreen() {
|
||||
<!-- Muted icon -->
|
||||
<svg
|
||||
v-if="muted"
|
||||
class="text-primary-comfy-ink size-3.5 lg:size-4"
|
||||
class="size-3.5 text-primary-comfy-ink lg:size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
@@ -349,7 +354,7 @@ function toggleFullscreen() {
|
||||
<!-- Unmuted icon -->
|
||||
<svg
|
||||
v-else
|
||||
class="text-primary-comfy-ink size-3.5 lg:size-4"
|
||||
class="size-3.5 text-primary-comfy-ink lg:size-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
|
||||
23
apps/website/src/components/icons/CheckIcon.vue
Normal file
23
apps/website/src/components/icons/CheckIcon.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
:class="$props.class"
|
||||
>
|
||||
<path
|
||||
d="M5 11.5811L10.2582 18.0581L20 6.05811"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
32
apps/website/src/components/icons/NodeUnionIcon.vue
Normal file
32
apps/website/src/components/icons/NodeUnionIcon.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
import { useId } from 'vue'
|
||||
|
||||
defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const clipId = `node-union-icon-clip-${useId()}`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
:class="$props.class"
|
||||
>
|
||||
<g :clip-path="`url(#${clipId})`">
|
||||
<path
|
||||
fill="currentColor"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M-1.59144e-05 0H100V100H-1.59144e-05V0ZM32.3741 50C32.3741 77.0727 16.2692 99.0196 -3.59714 99.0196C-23.4635 99.0196 -39.5684 77.0727 -39.5684 50C-39.5684 22.9273 -23.4635 0.980392 -3.59714 0.980392C16.2692 0.980392 32.3741 22.9273 32.3741 50ZM139.568 50C139.568 77.0727 123.463 99.0196 103.597 99.0196C83.7309 99.0196 67.6259 77.0727 67.6259 50C67.6259 22.9273 83.7309 0.980392 103.597 0.980392C123.463 0.980392 139.568 22.9273 139.568 50Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath :id="clipId">
|
||||
<rect width="100" height="100" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -21,7 +21,7 @@ const demoVideoPoster =
|
||||
<div class="flex flex-col gap-8">
|
||||
<div>
|
||||
<h2
|
||||
class="text-primary-comfy-canvas text-4xl font-light tracking-tight lg:text-6xl"
|
||||
class="text-3xl leading-[110%] font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
|
||||
>
|
||||
{{ t('learning.featured.title', locale) }}
|
||||
</h2>
|
||||
@@ -31,7 +31,7 @@ const demoVideoPoster =
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-primary-comfy-canvas max-w-md text-sm/relaxed lg:text-base"
|
||||
class="max-w-md text-sm/relaxed text-primary-comfy-canvas lg:text-base"
|
||||
>
|
||||
{{ t('learning.featured.description', locale) }}
|
||||
</p>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue'
|
||||
import { onMounted, onUnmounted, useTemplateRef } from 'vue'
|
||||
|
||||
import type { LearningTutorial } from '../../data/learningTutorials'
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { lockScroll, unlockScroll } from '../../composables/scrollLock'
|
||||
import { t } from '../../i18n/translations'
|
||||
import VideoPlayer from '../common/VideoPlayer.vue'
|
||||
|
||||
const { tutorial, locale = 'en' } = defineProps<{
|
||||
tutorial: LearningTutorial
|
||||
@@ -15,21 +16,6 @@ const { tutorial, locale = 'en' } = defineProps<{
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
|
||||
const dialogRef = useTemplateRef<HTMLDialogElement>('dialogRef')
|
||||
const videoRef = useTemplateRef<HTMLVideoElement>('videoRef')
|
||||
|
||||
const playFromStart = () => {
|
||||
const video = videoRef.value
|
||||
if (!video) return
|
||||
video.currentTime = 0
|
||||
void video.play().catch(() => {})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => tutorial.id,
|
||||
() => {
|
||||
playFromStart()
|
||||
}
|
||||
)
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) emit('close')
|
||||
@@ -42,7 +28,6 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
onMounted(() => {
|
||||
lockScroll()
|
||||
dialogRef.value?.showModal()
|
||||
playFromStart()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -62,31 +47,30 @@ onUnmounted(() => {
|
||||
>
|
||||
<button
|
||||
:aria-label="t('gallery.detail.close', locale)"
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink hover:bg-primary-comfy-yellow group absolute top-8 right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 transition-colors lg:right-26"
|
||||
class="border-primary-comfy-yellow hover:bg-primary-comfy-yellow group absolute top-8 right-10 z-10 flex size-10 cursor-pointer items-center justify-center rounded-2xl border-2 bg-primary-comfy-ink transition-colors lg:right-26"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<span
|
||||
class="bg-primary-comfy-yellow group-hover:bg-primary-comfy-ink size-5 transition-colors"
|
||||
class="bg-primary-comfy-yellow size-5 transition-colors group-hover:bg-primary-comfy-ink"
|
||||
style="mask: url('/icons/close.svg') center / contain no-repeat"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="border-primary-comfy-yellow bg-primary-comfy-ink rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 p-3 lg:p-4"
|
||||
class="border-primary-comfy-yellow rounded-5xl flex w-full max-w-7xl items-center justify-center overflow-hidden border-2 bg-primary-comfy-ink p-3 lg:p-4"
|
||||
>
|
||||
<video
|
||||
ref="videoRef"
|
||||
<VideoPlayer
|
||||
:key="tutorial.id"
|
||||
:locale
|
||||
:src="tutorial.videoSrc"
|
||||
:poster="tutorial.poster"
|
||||
class="aspect-video w-full rounded-3xl object-contain lg:rounded-4xl"
|
||||
controls
|
||||
autoplay
|
||||
playsinline
|
||||
></video>
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mt-6 text-center text-lg font-medium lg:text-xl"
|
||||
class="mt-6 text-center text-lg font-medium text-primary-comfy-canvas lg:text-xl"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}
|
||||
{{ tutorial.title[locale] }}
|
||||
|
||||
@@ -22,7 +22,7 @@ const activeTutorial = () =>
|
||||
<template>
|
||||
<section class="max-w-9xl mx-auto px-6 py-16 lg:py-24">
|
||||
<h2
|
||||
class="text-primary-comfy-canvas mb-12 text-4xl font-light tracking-tight lg:mb-16 lg:text-6xl"
|
||||
class="mb-12 text-4xl font-light tracking-tight text-primary-comfy-canvas lg:mb-16 lg:text-6xl"
|
||||
>
|
||||
{{ t('learning.tutorials.heading', locale) }}
|
||||
</h2>
|
||||
@@ -71,9 +71,9 @@ const activeTutorial = () =>
|
||||
<div class="flex flex-col space-y-3 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3
|
||||
class="text-primary-comfy-canvas text-sm/snug lg:text-base/snug"
|
||||
class="text-sm/snug text-primary-comfy-canvas lg:text-base/snug"
|
||||
>
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}<wbr />
|
||||
{{ t('learning.tutorials.titlePrefix', locale) }}<br />
|
||||
{{ tutorial.title[locale] }}
|
||||
</h3>
|
||||
<MaskRevealButton
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
import { t } from '../../i18n/translations'
|
||||
import CheckIcon from '../icons/CheckIcon.vue'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
@@ -94,12 +95,9 @@ const features: IncludedFeature[] = [
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<img
|
||||
<CheckIcon
|
||||
v-else
|
||||
src="/icons/check.svg"
|
||||
alt=""
|
||||
class="mt-0.5 size-4 shrink-0"
|
||||
aria-hidden="true"
|
||||
class="text-primary-comfy-yellow mt-0.5 size-4 shrink-0"
|
||||
/>
|
||||
<p class="text-primary-comfy-canvas text-sm font-medium">
|
||||
{{ t(feature.titleKey, locale) }}
|
||||
|
||||
44
apps/website/src/data/affiliateAudience.ts
Normal file
44
apps/website/src/data/affiliateAudience.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
interface AudienceCriterion {
|
||||
id: string
|
||||
label: LocalizedText
|
||||
}
|
||||
|
||||
export const affiliateAudienceCriteria: readonly AudienceCriterion[] = [
|
||||
{
|
||||
id: 'tutorial-creator',
|
||||
label: {
|
||||
en: 'A ComfyUI tutorial creator or workflow builder',
|
||||
'zh-CN': 'ComfyUI 教程作者或工作流创建者'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'ai-tool-reviewer',
|
||||
label: {
|
||||
en: 'An AI tool reviewer on YouTube, TikTok, blogs',
|
||||
'zh-CN': '在 YouTube、TikTok、博客上做 AI 工具测评'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'tech-blogger',
|
||||
label: {
|
||||
en: 'A tech blogger covering AI creative tools',
|
||||
'zh-CN': '报道 AI 创作工具的科技博主'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'newsletter-operator',
|
||||
label: {
|
||||
en: 'A newsletter operator in the AI/creative space',
|
||||
'zh-CN': 'AI/创意领域的简报运营者'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'audience-owner',
|
||||
label: {
|
||||
en: 'Anyone with an audience interested in AI image, video, or 3D',
|
||||
'zh-CN': '拥有关注 AI 图像、视频或 3D 受众的任何人'
|
||||
}
|
||||
}
|
||||
] as const
|
||||
39
apps/website/src/data/affiliateBenefits.ts
Normal file
39
apps/website/src/data/affiliateBenefits.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
interface AffiliateBenefit {
|
||||
id: string
|
||||
description: LocalizedText
|
||||
}
|
||||
|
||||
export const affiliateBenefits: readonly AffiliateBenefit[] = [
|
||||
{
|
||||
id: 'open-source-platform',
|
||||
description: {
|
||||
en: 'ComfyUI is the most powerful open-source AI creative platform',
|
||||
'zh-CN': 'ComfyUI 是最强大的开源 AI 创作平台'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'cloud-no-gpu',
|
||||
description: {
|
||||
en: 'Comfy Cloud lets you run ComfyUI in the browser, no GPU needed, all models pre-loaded',
|
||||
'zh-CN':
|
||||
'Comfy Cloud 让你在浏览器中运行 ComfyUI,无需 GPU,所有模型预加载'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'node-based-control',
|
||||
description: {
|
||||
en: 'Node-based workflows give users full creative control unlike prompt-only tools',
|
||||
'zh-CN':
|
||||
'基于节点的工作流让用户拥有完整的创作控制力,区别于仅靠提示词的工具'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'custom-nodes',
|
||||
description: {
|
||||
en: '1,000+ community custom node packages',
|
||||
'zh-CN': '1,000+ 社区自定义节点包'
|
||||
}
|
||||
}
|
||||
] as const
|
||||
38
apps/website/src/data/affiliateBrandAssets.ts
Normal file
38
apps/website/src/data/affiliateBrandAssets.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
interface AffiliateBrandAsset {
|
||||
id: string
|
||||
title: LocalizedText
|
||||
download: string
|
||||
preview: string
|
||||
}
|
||||
|
||||
const BRAND_ASSETS_ZIP =
|
||||
'https://media.comfy.org/website/comfy-org-brand-assets.zip'
|
||||
|
||||
export const affiliateBrandAssets: readonly AffiliateBrandAsset[] = [
|
||||
{
|
||||
id: 'core-logo',
|
||||
title: { en: 'Core Logo', 'zh-CN': '核心标志' },
|
||||
download: BRAND_ASSETS_ZIP,
|
||||
preview: '/icons/logo.svg'
|
||||
},
|
||||
{
|
||||
id: 'logomark',
|
||||
title: { en: 'Logomark', 'zh-CN': '标志符号' },
|
||||
download: BRAND_ASSETS_ZIP,
|
||||
preview: '/icons/logomark.svg'
|
||||
},
|
||||
{
|
||||
id: 'icon',
|
||||
title: { en: 'Icon', 'zh-CN': '图标' },
|
||||
download: BRAND_ASSETS_ZIP,
|
||||
preview: '/affiliates/brand/comfy-color-combo-yellow.svg'
|
||||
},
|
||||
{
|
||||
id: 'amplified-logomark',
|
||||
title: { en: 'Amplified Logomark', 'zh-CN': '放大版标志符号' },
|
||||
download: BRAND_ASSETS_ZIP,
|
||||
preview: '/affiliates/brand/comfy-amplified-logo.png'
|
||||
}
|
||||
] as const
|
||||
102
apps/website/src/data/affiliateFaq.ts
Normal file
102
apps/website/src/data/affiliateFaq.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
interface AffiliateFaq {
|
||||
id: string
|
||||
question: LocalizedText
|
||||
answer: LocalizedText
|
||||
}
|
||||
|
||||
export const affiliateFaqs: readonly AffiliateFaq[] = [
|
||||
{
|
||||
id: 'how-do-i-track-my-referrals',
|
||||
question: {
|
||||
en: 'How do I track my referrals?',
|
||||
'zh-CN': '我如何追踪我的推荐?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Real-time dashboard via our partner portal.',
|
||||
'zh-CN': '通过我们的合作伙伴门户使用实时仪表盘追踪。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'what-plans-qualify',
|
||||
question: {
|
||||
en: 'What plans qualify?',
|
||||
'zh-CN': '哪些订阅方案符合条件?'
|
||||
},
|
||||
answer: {
|
||||
en: 'All Comfy Cloud paid subscription plans (Creator, Pro, Teams).',
|
||||
'zh-CN': '所有 Comfy Cloud 付费订阅方案(Creator、Pro、Teams)。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'how-long-to-get-approved',
|
||||
question: {
|
||||
en: 'How long does approval take?',
|
||||
'zh-CN': '审核需要多长时间?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Most applications approved within 24 hours.',
|
||||
'zh-CN': '大多数申请会在 24 小时内获批。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'when-do-i-get-paid',
|
||||
question: {
|
||||
en: 'When do I get paid?',
|
||||
'zh-CN': '什么时候结算佣金?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Monthly, within the first 10 business days. Minimum balance $100. Paid via Stripe Express or PayPal.',
|
||||
'zh-CN':
|
||||
'每月结算,于每月前 10 个工作日内发放。最低结算余额为 100 美元,通过 Stripe Express 或 PayPal 支付。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'what-happens-if-referral-upgrades-or-downgrades',
|
||||
question: {
|
||||
en: 'What happens if my referral upgrades or downgrades?',
|
||||
'zh-CN': '如果我推荐的用户升级或降级订阅会怎样?'
|
||||
},
|
||||
answer: {
|
||||
en: 'If they upgrade, your commission increases. If they downgrade, it adjusts accordingly. Commission is based on actual amounts received by Comfy.org, net of refunds.',
|
||||
'zh-CN':
|
||||
'如果他们升级订阅,您的佣金会相应增加;如果降级,佣金也会同步调整。佣金以 Comfy.org 实际收到的金额为准,并扣除退款部分。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'can-i-use-coupon-codes',
|
||||
question: {
|
||||
en: 'Can I use coupon codes?',
|
||||
'zh-CN': '我可以使用优惠码吗?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Yes. We support both tracking links and unique coupon codes.',
|
||||
'zh-CN': '可以。我们同时支持追踪链接和专属优惠码。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'what-if-my-referral-uses-an-ad-blocker',
|
||||
question: {
|
||||
en: 'What if my referral uses an ad blocker?',
|
||||
'zh-CN': '如果我推荐的用户使用广告拦截器怎么办?'
|
||||
},
|
||||
answer: {
|
||||
en: 'We use server-side tracking, so conversions are tracked regardless.',
|
||||
'zh-CN':
|
||||
'我们采用服务端追踪,因此无论用户是否使用广告拦截器,转化都能正常记录。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'what-assets-do-you-provide',
|
||||
question: {
|
||||
en: 'What assets do you provide?',
|
||||
'zh-CN': '你们提供哪些素材?'
|
||||
},
|
||||
answer: {
|
||||
en: 'Logos and banners on this page, plus screenshots and talking points in your affiliate dashboard after approval.',
|
||||
'zh-CN':
|
||||
'本页面提供 Logo 和横幅图,获批后您还可以在联盟仪表盘中获取截图和宣传文案。'
|
||||
}
|
||||
}
|
||||
] as const
|
||||
45
apps/website/src/data/affiliateHowItWorks.ts
Normal file
45
apps/website/src/data/affiliateHowItWorks.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { LocalizedText } from '../i18n/translations'
|
||||
|
||||
interface HowItWorksStep {
|
||||
id: string
|
||||
label: LocalizedText
|
||||
description: LocalizedText
|
||||
}
|
||||
|
||||
export const affiliateHowItWorksSteps: readonly HowItWorksStep[] = [
|
||||
{
|
||||
id: 'apply',
|
||||
label: {
|
||||
en: 'Apply',
|
||||
'zh-CN': '申请'
|
||||
},
|
||||
description: {
|
||||
en: 'Submit a quick form. Most applicants approved same day.',
|
||||
'zh-CN': '填写一份简短表单。大多数申请当天获批。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'share',
|
||||
label: {
|
||||
en: 'Share',
|
||||
'zh-CN': '分享'
|
||||
},
|
||||
description: {
|
||||
en: 'Get your unique tracking link. Share via content, social, email, however you reach your audience.',
|
||||
'zh-CN':
|
||||
'获取您的专属追踪链接。通过内容、社交、邮件等任何触达受众的方式分享。'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'earn',
|
||||
label: {
|
||||
en: 'Earn',
|
||||
'zh-CN': '赚取'
|
||||
},
|
||||
description: {
|
||||
en: '30% recurring commission for 3 months on every Comfy Cloud subscriber you refer. Tracked in real-time. Paid monthly.',
|
||||
'zh-CN':
|
||||
'每位您推荐的 Comfy Cloud 订阅者,可获连续 3 个月 30% 的经常性佣金。实时追踪,每月结算。'
|
||||
}
|
||||
}
|
||||
] as const
|
||||
@@ -4501,313 +4501,103 @@ const translations = {
|
||||
'zh-CN': '查看订阅文档'
|
||||
},
|
||||
|
||||
// Affiliate landing page (/affiliates)
|
||||
// English-only copy; zh-CN values mirror en until a localized version lands.
|
||||
'affiliate-landing.page.title': {
|
||||
en: 'Comfy.org Affiliate Program — Become a Partner',
|
||||
'zh-CN': 'Comfy.org Affiliate Program — Become a Partner'
|
||||
// AffiliateHeroSection
|
||||
'affiliate.hero.label': { en: 'AFFILIATE', 'zh-CN': '联盟' },
|
||||
'affiliate.hero.headingHighlight': {
|
||||
en: 'Earn 30%',
|
||||
'zh-CN': '赚取 30%'
|
||||
},
|
||||
'affiliate-landing.page.description': {
|
||||
'affiliate.hero.headingMuted': {
|
||||
en: 'recurring commission for 3 months.',
|
||||
'zh-CN': '持续返佣 3 个月。'
|
||||
},
|
||||
'affiliate.hero.feature1': {
|
||||
en: '30% recurring commission for 3 months',
|
||||
'zh-CN': '30% 持续佣金,连续 3 个月'
|
||||
},
|
||||
'affiliate.hero.feature2': {
|
||||
en: '60-day cookie window',
|
||||
'zh-CN': '60 天 Cookie 窗口'
|
||||
},
|
||||
'affiliate.hero.feature3': {
|
||||
en: '$100 minimum payout',
|
||||
'zh-CN': '$100 起付'
|
||||
},
|
||||
'affiliate.hero.feature4': {
|
||||
en: 'Monthly payouts',
|
||||
'zh-CN': '每月结算'
|
||||
},
|
||||
'affiliate.hero.apply': { en: 'APPLY NOW', 'zh-CN': '立即申请' },
|
||||
'affiliate.hero.imageAlt': {
|
||||
en: 'Comfy affiliate program',
|
||||
'zh-CN': 'Comfy 联盟计划'
|
||||
},
|
||||
|
||||
// AffiliateAudienceSection
|
||||
'affiliate.audience.heading': {
|
||||
en: "Who we're looking for",
|
||||
'zh-CN': '我们在寻找谁'
|
||||
},
|
||||
'affiliate.audience.subheading': {
|
||||
en: 'If you are...',
|
||||
'zh-CN': '如果您是……'
|
||||
},
|
||||
|
||||
// AffiliateHowItWorksSection
|
||||
'affiliate.howItWorks.heading': {
|
||||
en: 'How it works',
|
||||
'zh-CN': '运作方式'
|
||||
},
|
||||
|
||||
// AffiliateBenefitsSection
|
||||
'affiliate.benefits.heading': {
|
||||
en: 'Why ComfyUI for affiliate creators',
|
||||
'zh-CN': '为什么联盟创作者选择 ComfyUI'
|
||||
},
|
||||
|
||||
// AffiliateBrandAssetsSection
|
||||
'affiliate.assets.heading': {
|
||||
en: 'Brand logos for your content',
|
||||
'zh-CN': '可用于您内容的品牌 Logo'
|
||||
},
|
||||
'affiliate.assets.subheading': {
|
||||
en: 'Banners, screenshots, and talking points are in your affiliate dashboard after approval.',
|
||||
'zh-CN': '横幅图、截图和宣传文案将在获批后于联盟仪表盘中提供。'
|
||||
},
|
||||
'affiliate.assets.downloadLabel': {
|
||||
en: 'Download zip',
|
||||
'zh-CN': '下载压缩包'
|
||||
},
|
||||
|
||||
// AffiliateFAQSection
|
||||
'affiliate.faq.heading': {
|
||||
en: 'Frequently asked questions',
|
||||
'zh-CN': '常见问题'
|
||||
},
|
||||
|
||||
// Affiliate page (/affiliates) — head metadata
|
||||
'affiliate.page.title': {
|
||||
en: 'Comfy.org Affiliate Program — Become a Partner',
|
||||
'zh-CN': 'Comfy.org 联盟计划 — 成为合作伙伴'
|
||||
},
|
||||
'affiliate.page.description': {
|
||||
en: 'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.',
|
||||
'zh-CN':
|
||||
'Earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer. Apply to become a Comfy Partner.'
|
||||
},
|
||||
'affiliate-landing.cta.apply': {
|
||||
en: 'Apply Now',
|
||||
'zh-CN': 'Apply Now'
|
||||
},
|
||||
'affiliate-landing.cta.applyAriaLabel': {
|
||||
en: 'Apply Now (opens in new tab)',
|
||||
'zh-CN': 'Apply Now (opens in new tab)'
|
||||
'为您推荐的每个 Comfy Cloud 订阅赚取 30% 持续佣金,连续 3 个月。立即申请成为 Comfy 合作伙伴。'
|
||||
},
|
||||
|
||||
// Hero
|
||||
'affiliate-landing.hero.heading': {
|
||||
en: 'Become a Comfy Partner',
|
||||
'zh-CN': 'Become a Comfy Partner'
|
||||
},
|
||||
'affiliate-landing.hero.subheading': {
|
||||
en: 'Earn 30% Commission for 3 Months.',
|
||||
'zh-CN': 'Earn 30% Commission for 3 Months.'
|
||||
},
|
||||
'affiliate-landing.hero.body': {
|
||||
en: 'Join the Comfy.org Affiliate Program and earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer:',
|
||||
'zh-CN':
|
||||
'Join the Comfy.org Affiliate Program and earn 30% recurring commission for 3 months on every Comfy Cloud subscription you refer:'
|
||||
},
|
||||
'affiliate-landing.hero.highlight.0': {
|
||||
en: '30% recurring commission for 3 months',
|
||||
'zh-CN': '30% recurring commission for 3 months'
|
||||
},
|
||||
'affiliate-landing.hero.highlight.1': {
|
||||
en: '60-day cookie window',
|
||||
'zh-CN': '60-day cookie window'
|
||||
},
|
||||
'affiliate-landing.hero.highlight.2': {
|
||||
en: '$100 minimum payout',
|
||||
'zh-CN': '$100 minimum payout'
|
||||
},
|
||||
'affiliate-landing.hero.highlight.3': {
|
||||
en: 'Monthly payouts',
|
||||
'zh-CN': 'Monthly payouts'
|
||||
},
|
||||
|
||||
// Trust band
|
||||
'affiliate-landing.trust.label': {
|
||||
en: 'Trusted by 2M+ creators worldwide',
|
||||
'zh-CN': 'Trusted by 2M+ creators worldwide'
|
||||
},
|
||||
|
||||
// How it works
|
||||
'affiliate-landing.how.heading': {
|
||||
en: 'How it works',
|
||||
'zh-CN': 'How it works'
|
||||
},
|
||||
'affiliate-landing.how.step.0.title': {
|
||||
en: 'Apply.',
|
||||
'zh-CN': 'Apply.'
|
||||
},
|
||||
'affiliate-landing.how.step.0.body': {
|
||||
en: 'Submit a quick form. Most applicants approved same day.',
|
||||
'zh-CN': 'Submit a quick form. Most applicants approved same day.'
|
||||
},
|
||||
'affiliate-landing.how.step.1.title': {
|
||||
en: 'Share.',
|
||||
'zh-CN': 'Share.'
|
||||
},
|
||||
'affiliate-landing.how.step.1.body': {
|
||||
en: 'Get your unique tracking link. Share via content, social, email, however you reach your audience.',
|
||||
'zh-CN':
|
||||
'Get your unique tracking link. Share via content, social, email, however you reach your audience.'
|
||||
},
|
||||
'affiliate-landing.how.step.2.title': {
|
||||
en: 'Earn.',
|
||||
'zh-CN': 'Earn.'
|
||||
},
|
||||
'affiliate-landing.how.step.2.body': {
|
||||
en: '30% recurring commission for 3 months on every Comfy Cloud subscriber you refer. Tracked in real-time. Paid monthly.',
|
||||
'zh-CN':
|
||||
'30% recurring commission for 3 months on every Comfy Cloud subscriber you refer. Tracked in real-time. Paid monthly.'
|
||||
},
|
||||
|
||||
// Who we're looking for
|
||||
'affiliate-landing.audience.heading': {
|
||||
en: "Who we're looking for",
|
||||
'zh-CN': "Who we're looking for"
|
||||
},
|
||||
'affiliate-landing.audience.item.0': {
|
||||
en: 'ComfyUI tutorial creators and workflow builders',
|
||||
'zh-CN': 'ComfyUI tutorial creators and workflow builders'
|
||||
},
|
||||
'affiliate-landing.audience.item.1': {
|
||||
en: 'AI tool reviewers on YouTube, TikTok, blogs',
|
||||
'zh-CN': 'AI tool reviewers on YouTube, TikTok, blogs'
|
||||
},
|
||||
'affiliate-landing.audience.item.2': {
|
||||
en: 'Tech bloggers covering AI creative tools',
|
||||
'zh-CN': 'Tech bloggers covering AI creative tools'
|
||||
},
|
||||
'affiliate-landing.audience.item.3': {
|
||||
en: 'Newsletter operators in the AI/creative space',
|
||||
'zh-CN': 'Newsletter operators in the AI/creative space'
|
||||
},
|
||||
'affiliate-landing.audience.item.4': {
|
||||
en: 'Anyone with an audience interested in AI image, video, or 3D generation',
|
||||
'zh-CN':
|
||||
'Anyone with an audience interested in AI image, video, or 3D generation'
|
||||
},
|
||||
|
||||
// Program details
|
||||
'affiliate-landing.details.heading': {
|
||||
en: 'Program details',
|
||||
'zh-CN': 'Program details'
|
||||
},
|
||||
'affiliate-landing.details.headerLabel': {
|
||||
en: 'Detail',
|
||||
'zh-CN': 'Detail'
|
||||
},
|
||||
'affiliate-landing.details.headerValue': {
|
||||
en: 'Value',
|
||||
'zh-CN': 'Value'
|
||||
},
|
||||
'affiliate-landing.details.row.0.label': {
|
||||
en: 'Commission rate',
|
||||
'zh-CN': 'Commission rate'
|
||||
},
|
||||
'affiliate-landing.details.row.0.value': {
|
||||
en: '30% recurring',
|
||||
'zh-CN': '30% recurring'
|
||||
},
|
||||
'affiliate-landing.details.row.1.label': {
|
||||
en: 'Commission duration',
|
||||
'zh-CN': 'Commission duration'
|
||||
},
|
||||
'affiliate-landing.details.row.1.value': {
|
||||
en: '3 months',
|
||||
'zh-CN': '3 months'
|
||||
},
|
||||
'affiliate-landing.details.row.2.label': {
|
||||
en: 'Cookie window',
|
||||
'zh-CN': 'Cookie window'
|
||||
},
|
||||
'affiliate-landing.details.row.2.value': {
|
||||
en: '60 days',
|
||||
'zh-CN': '60 days'
|
||||
},
|
||||
'affiliate-landing.details.row.3.label': {
|
||||
en: 'Eligible products',
|
||||
'zh-CN': 'Eligible products'
|
||||
},
|
||||
'affiliate-landing.details.row.3.value': {
|
||||
en: 'Comfy Cloud paid subscription plans',
|
||||
'zh-CN': 'Comfy Cloud paid subscription plans'
|
||||
},
|
||||
'affiliate-landing.details.row.4.label': {
|
||||
en: 'Payouts',
|
||||
'zh-CN': 'Payouts'
|
||||
},
|
||||
'affiliate-landing.details.row.4.value': {
|
||||
en: 'Monthly, within first 10 business days',
|
||||
'zh-CN': 'Monthly, within first 10 business days'
|
||||
},
|
||||
'affiliate-landing.details.row.5.label': {
|
||||
en: 'Minimum payout',
|
||||
'zh-CN': 'Minimum payout'
|
||||
},
|
||||
'affiliate-landing.details.row.5.value': {
|
||||
en: '$100',
|
||||
'zh-CN': '$100'
|
||||
},
|
||||
|
||||
// Brand assets
|
||||
'affiliate-landing.assets.heading': {
|
||||
en: 'Brand assets',
|
||||
'zh-CN': 'Brand assets'
|
||||
},
|
||||
'affiliate-landing.assets.subheading': {
|
||||
en: 'Approved Comfy logos for your content. Banners, screenshots, and talking points are in your affiliate dashboard after approval.',
|
||||
'zh-CN':
|
||||
'Approved Comfy logos for your content. Banners, screenshots, and talking points are in your affiliate dashboard after approval.'
|
||||
},
|
||||
'affiliate-landing.assets.downloadLabel': {
|
||||
en: 'Download',
|
||||
'zh-CN': 'Download'
|
||||
},
|
||||
'affiliate-landing.assets.tile.logo-horizontal.title': {
|
||||
en: 'Comfy logo (horizontal)',
|
||||
'zh-CN': 'Comfy logo (horizontal)'
|
||||
},
|
||||
'affiliate-landing.assets.tile.logomark.title': {
|
||||
en: 'Comfy logomark',
|
||||
'zh-CN': 'Comfy logomark'
|
||||
},
|
||||
'affiliate-landing.assets.tile.comfy-full-logo-yellow.title': {
|
||||
en: 'Comfy full logo (yellow)',
|
||||
'zh-CN': 'Comfy full logo (yellow)'
|
||||
},
|
||||
'affiliate-landing.assets.tile.comfy-full-logo-ink.title': {
|
||||
en: 'Comfy full logo (ink)',
|
||||
'zh-CN': 'Comfy full logo (ink)'
|
||||
},
|
||||
'affiliate-landing.assets.tile.amplified-logo-mark.title': {
|
||||
en: 'Amplified logo mark',
|
||||
'zh-CN': 'Amplified logo mark'
|
||||
},
|
||||
'affiliate-landing.assets.tile.dimensional-logo-mark.title': {
|
||||
en: 'Dimensional logo mark',
|
||||
'zh-CN': 'Dimensional logo mark'
|
||||
},
|
||||
'affiliate-landing.assets.tile.color-combo-yellow.title': {
|
||||
en: 'Color combo (yellow)',
|
||||
'zh-CN': 'Color combo (yellow)'
|
||||
},
|
||||
'affiliate-landing.assets.tile.color-combo-ink.title': {
|
||||
en: 'Color combo (ink)',
|
||||
'zh-CN': 'Color combo (ink)'
|
||||
},
|
||||
|
||||
// FAQ — keys follow the FAQSection contract: <prefix>.<n>.q / <prefix>.<n>.a, 1-indexed
|
||||
'affiliate-landing.faq.heading': {
|
||||
en: 'Questions',
|
||||
'zh-CN': 'Questions'
|
||||
},
|
||||
'affiliate-landing.faq.1.q': {
|
||||
en: 'How do I track my referrals?',
|
||||
'zh-CN': 'How do I track my referrals?'
|
||||
},
|
||||
'affiliate-landing.faq.1.a': {
|
||||
en: 'Real-time dashboard via our partner portal.',
|
||||
'zh-CN': 'Real-time dashboard via our partner portal.'
|
||||
},
|
||||
'affiliate-landing.faq.2.q': {
|
||||
en: 'What plans qualify?',
|
||||
'zh-CN': 'What plans qualify?'
|
||||
},
|
||||
'affiliate-landing.faq.2.a': {
|
||||
en: 'All Comfy Cloud paid subscription plans (Creator, Pro, Teams).',
|
||||
'zh-CN': 'All Comfy Cloud paid subscription plans (Creator, Pro, Teams).'
|
||||
},
|
||||
'affiliate-landing.faq.3.q': {
|
||||
en: 'How long does approval take?',
|
||||
'zh-CN': 'How long does approval take?'
|
||||
},
|
||||
'affiliate-landing.faq.3.a': {
|
||||
en: 'Most applications approved within 24 hours.',
|
||||
'zh-CN': 'Most applications approved within 24 hours.'
|
||||
},
|
||||
'affiliate-landing.faq.4.q': {
|
||||
en: 'When do I get paid?',
|
||||
'zh-CN': 'When do I get paid?'
|
||||
},
|
||||
'affiliate-landing.faq.4.a': {
|
||||
en: 'Monthly, within the first 10 business days. Minimum balance $100. Paid via Stripe Express or PayPal.',
|
||||
'zh-CN':
|
||||
'Monthly, within the first 10 business days. Minimum balance $100. Paid via Stripe Express or PayPal.'
|
||||
},
|
||||
'affiliate-landing.faq.5.q': {
|
||||
en: 'What happens if my referral upgrades or downgrades?',
|
||||
'zh-CN': 'What happens if my referral upgrades or downgrades?'
|
||||
},
|
||||
'affiliate-landing.faq.5.a': {
|
||||
en: 'If they upgrade, your commission increases. If they downgrade, it adjusts accordingly. Commission is based on actual amounts received by Comfy.org, net of refunds.',
|
||||
'zh-CN':
|
||||
'If they upgrade, your commission increases. If they downgrade, it adjusts accordingly. Commission is based on actual amounts received by Comfy.org, net of refunds.'
|
||||
},
|
||||
'affiliate-landing.faq.6.q': {
|
||||
en: 'Can I use coupon codes?',
|
||||
'zh-CN': 'Can I use coupon codes?'
|
||||
},
|
||||
'affiliate-landing.faq.6.a': {
|
||||
en: 'Yes. We support both tracking links and unique coupon codes.',
|
||||
'zh-CN': 'Yes. We support both tracking links and unique coupon codes.'
|
||||
},
|
||||
'affiliate-landing.faq.7.q': {
|
||||
en: 'What if my referral uses an ad blocker?',
|
||||
'zh-CN': 'What if my referral uses an ad blocker?'
|
||||
},
|
||||
'affiliate-landing.faq.7.a': {
|
||||
en: 'We use server-side tracking, so conversions are tracked regardless.',
|
||||
'zh-CN':
|
||||
'We use server-side tracking, so conversions are tracked regardless.'
|
||||
},
|
||||
'affiliate-landing.faq.8.q': {
|
||||
en: 'What assets do you provide?',
|
||||
'zh-CN': 'What assets do you provide?'
|
||||
},
|
||||
'affiliate-landing.faq.8.a': {
|
||||
en: 'Logos and banners on this page, plus screenshots and talking points in your affiliate dashboard after approval.',
|
||||
'zh-CN':
|
||||
'Logos and banners on this page, plus screenshots and talking points in your affiliate dashboard after approval.'
|
||||
},
|
||||
|
||||
// Footer CTA
|
||||
'affiliate-landing.footerCta.heading': {
|
||||
// AffiliateCtaSection
|
||||
'affiliate.cta.heading': {
|
||||
en: 'Ready to start earning?',
|
||||
'zh-CN': 'Ready to start earning?'
|
||||
'zh-CN': '准备好开始赚取佣金了吗?'
|
||||
},
|
||||
'affiliate-landing.footerCta.termsLink': {
|
||||
'affiliate.cta.apply': {
|
||||
en: 'APPLY NOW',
|
||||
'zh-CN': '立即申请'
|
||||
},
|
||||
'affiliate.cta.termsLabel': {
|
||||
en: 'Read the affiliate program terms',
|
||||
'zh-CN': 'Read the affiliate program terms'
|
||||
'zh-CN': '阅读联盟计划条款'
|
||||
}
|
||||
} as const satisfies Record<string, Record<Locale, string>>
|
||||
|
||||
|
||||
@@ -1,44 +1,34 @@
|
||||
---
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import AudienceSection from '../../components/affiliates/AudienceSection.vue'
|
||||
import BrandAssetsSection from '../../components/affiliates/BrandAssetsSection.vue'
|
||||
import FooterCtaSection from '../../components/affiliates/FooterCtaSection.vue'
|
||||
import HeroSection from '../../components/affiliates/HeroSection.vue'
|
||||
import HowItWorksSection from '../../components/affiliates/HowItWorksSection.vue'
|
||||
import ProgramDetailsSection from '../../components/affiliates/ProgramDetailsSection.vue'
|
||||
import TrustBandSection from '../../components/affiliates/TrustBandSection.vue'
|
||||
import FAQSection from '../../components/common/FAQSection.vue'
|
||||
import {
|
||||
AFFILIATE_FAQ_COUNT,
|
||||
AFFILIATE_FAQ_HEADING_KEY,
|
||||
AFFILIATE_FAQ_PREFIX
|
||||
} from '../../components/affiliates/affiliateFaqs'
|
||||
import type { Locale, TranslationKey } from '../../i18n/translations'
|
||||
import AudienceSection from '../../templates/affiliate/AudienceSection.vue'
|
||||
import BenefitsSection from '../../templates/affiliate/BenefitsSection.vue'
|
||||
import BrandAssetsSection from '../../templates/affiliate/BrandAssetsSection.vue'
|
||||
import CtaSection from '../../templates/affiliate/CtaSection.vue'
|
||||
import FAQSection from '../../templates/affiliate/FAQSection.vue'
|
||||
import HeroSection from '../../templates/affiliate/HeroSection.vue'
|
||||
import HowItWorksSection from '../../templates/affiliate/HowItWorksSection.vue'
|
||||
import { affiliateFaqs } from '../../data/affiliateFaq'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const locale: Locale =
|
||||
Astro.currentLocale === 'zh-CN' ? 'zh-CN' : 'en'
|
||||
const locale = 'en' as const
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: Array.from({ length: AFFILIATE_FAQ_COUNT }, (_, i) => {
|
||||
const n = i + 1
|
||||
return {
|
||||
'@type': 'Question',
|
||||
name: t(`${AFFILIATE_FAQ_PREFIX}.${n}.q` as TranslationKey, locale),
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: t(`${AFFILIATE_FAQ_PREFIX}.${n}.a` as TranslationKey, locale)
|
||||
}
|
||||
mainEntity: affiliateFaqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question[locale],
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer[locale]
|
||||
}
|
||||
})
|
||||
}))
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
title={t('affiliate-landing.page.title', locale)}
|
||||
description={t('affiliate-landing.page.description', locale)}
|
||||
title={t('affiliate.page.title', locale)}
|
||||
description={t('affiliate.page.description', locale)}
|
||||
>
|
||||
<Fragment slot="head">
|
||||
<script
|
||||
@@ -48,18 +38,11 @@ const faqJsonLd = {
|
||||
/>
|
||||
</Fragment>
|
||||
|
||||
<HeroSection locale={locale} client:load />
|
||||
<TrustBandSection locale={locale} />
|
||||
<HowItWorksSection locale={locale} />
|
||||
<AudienceSection locale={locale} />
|
||||
<ProgramDetailsSection locale={locale} />
|
||||
<BrandAssetsSection locale={locale} />
|
||||
<FAQSection
|
||||
locale={locale}
|
||||
headingKey={AFFILIATE_FAQ_HEADING_KEY}
|
||||
faqPrefix={AFFILIATE_FAQ_PREFIX}
|
||||
faqCount={AFFILIATE_FAQ_COUNT}
|
||||
client:load
|
||||
/>
|
||||
<FooterCtaSection locale={locale} client:load />
|
||||
<HeroSection />
|
||||
<HowItWorksSection />
|
||||
<AudienceSection />
|
||||
<BenefitsSection />
|
||||
<BrandAssetsSection />
|
||||
<FAQSection client:visible />
|
||||
<CtaSection />
|
||||
</BaseLayout>
|
||||
|
||||
22
apps/website/src/templates/affiliate/AudienceSection.vue
Normal file
22
apps/website/src/templates/affiliate/AudienceSection.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import ChecklistSplit01 from '../../components/blocks/ChecklistSplit01.vue'
|
||||
import { affiliateAudienceCriteria } from '../../data/affiliateAudience'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const criteria = affiliateAudienceCriteria.map((criterion) => ({
|
||||
id: criterion.id,
|
||||
label: criterion.label[locale]
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ChecklistSplit01
|
||||
:heading="t('affiliate.audience.heading', locale)"
|
||||
:subheading="t('affiliate.audience.subheading', locale)"
|
||||
:criteria="criteria"
|
||||
/>
|
||||
</template>
|
||||
27
apps/website/src/templates/affiliate/BenefitsSection.vue
Normal file
27
apps/website/src/templates/affiliate/BenefitsSection.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import BenefitsGrid01 from '../../components/blocks/BenefitsGrid01.vue'
|
||||
import { externalLinks } from '../../config/routes'
|
||||
import { affiliateBenefits } from '../../data/affiliateBenefits'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const benefits = affiliateBenefits.map((benefit) => ({
|
||||
id: benefit.id,
|
||||
description: benefit.description[locale]
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BenefitsGrid01
|
||||
:heading="t('affiliate.benefits.heading', locale)"
|
||||
:benefits="benefits"
|
||||
:primary-cta="{
|
||||
label: t('affiliate.hero.apply', locale),
|
||||
href: externalLinks.affiliateApplicationForm,
|
||||
target: '_blank'
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
25
apps/website/src/templates/affiliate/BrandAssetsSection.vue
Normal file
25
apps/website/src/templates/affiliate/BrandAssetsSection.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import BrandAssetsGrid01 from '../../components/blocks/BrandAssetsGrid01.vue'
|
||||
import { affiliateBrandAssets } from '../../data/affiliateBrandAssets'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const assets = affiliateBrandAssets.map((asset) => ({
|
||||
id: asset.id,
|
||||
title: asset.title[locale],
|
||||
download: asset.download,
|
||||
preview: asset.preview
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BrandAssetsGrid01
|
||||
:heading="t('affiliate.assets.heading', locale)"
|
||||
:subheading="t('affiliate.assets.subheading', locale)"
|
||||
:download-label="t('affiliate.assets.downloadLabel', locale)"
|
||||
:assets="assets"
|
||||
/>
|
||||
</template>
|
||||
26
apps/website/src/templates/affiliate/CtaSection.vue
Normal file
26
apps/website/src/templates/affiliate/CtaSection.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import CtaCenter01 from '../../components/blocks/CtaCenter01.vue'
|
||||
import { externalLinks, getRoutes } from '../../config/routes'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const routes = getRoutes(locale)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CtaCenter01
|
||||
:heading="t('affiliate.cta.heading', locale)"
|
||||
:primary-cta="{
|
||||
label: t('affiliate.cta.apply', locale),
|
||||
href: externalLinks.affiliateApplicationForm,
|
||||
target: '_blank'
|
||||
}"
|
||||
:terms-link="{
|
||||
label: t('affiliate.cta.termsLabel', locale),
|
||||
href: routes.affiliateTerms
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
19
apps/website/src/templates/affiliate/FAQSection.vue
Normal file
19
apps/website/src/templates/affiliate/FAQSection.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import FAQSplit01 from '../../components/blocks/FAQSplit01.vue'
|
||||
import { affiliateFaqs } from '../../data/affiliateFaq'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const faqs = affiliateFaqs.map((faq) => ({
|
||||
id: faq.id,
|
||||
question: faq.question[locale],
|
||||
answer: faq.answer[locale]
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FAQSplit01 :heading="t('affiliate.faq.heading', locale)" :faqs="faqs" />
|
||||
</template>
|
||||
32
apps/website/src/templates/affiliate/HeroSection.vue
Normal file
32
apps/website/src/templates/affiliate/HeroSection.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import { t } from '../../i18n/translations'
|
||||
import HeroSplit01 from '../../components/blocks/HeroSplit01.vue'
|
||||
import { externalLinks } from '@/config/routes.ts'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HeroSplit01
|
||||
:badge-text="t('affiliate.hero.label', locale)"
|
||||
:title-highlight="t('affiliate.hero.headingHighlight', locale)"
|
||||
:title="t('affiliate.hero.headingMuted', locale)"
|
||||
:features="[
|
||||
t('affiliate.hero.feature1', locale),
|
||||
t('affiliate.hero.feature2', locale),
|
||||
t('affiliate.hero.feature3', locale),
|
||||
t('affiliate.hero.feature4', locale)
|
||||
]"
|
||||
:primary-cta="{
|
||||
label: t('affiliate.hero.apply', locale),
|
||||
href: externalLinks.affiliateApplicationForm
|
||||
}"
|
||||
video-autoplay
|
||||
video-loop
|
||||
video-hide-controls
|
||||
video-src="https://media.comfy.org/website/affiliates/rainlit-ronin_compressed.mp4"
|
||||
:image-alt="t('affiliate.hero.imageAlt', locale)"
|
||||
/>
|
||||
</template>
|
||||
22
apps/website/src/templates/affiliate/HowItWorksSection.vue
Normal file
22
apps/website/src/templates/affiliate/HowItWorksSection.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import type { Locale } from '../../i18n/translations'
|
||||
|
||||
import StepsGrid01 from '../../components/blocks/StepsGrid01.vue'
|
||||
import { affiliateHowItWorksSteps } from '../../data/affiliateHowItWorks'
|
||||
import { t } from '../../i18n/translations'
|
||||
|
||||
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
|
||||
|
||||
const steps = affiliateHowItWorksSteps.map((step) => ({
|
||||
id: step.id,
|
||||
label: step.label[locale],
|
||||
description: step.description[locale]
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StepsGrid01
|
||||
:heading="t('affiliate.howItWorks.heading', locale)"
|
||||
:steps="steps"
|
||||
/>
|
||||
</template>
|
||||
Reference in New Issue
Block a user