← Back to Docs / Technical / Technical Execution

urWhats.com - Technical Execution Plan

Created: 2026-03-09 Status: READY FOR EXECUTION Total: 9 Tasks Target: AI-agent-ready with exact file paths, code changes, and acceptance criteria

Stack: Astro 5.18 (static SSG), Tailwind CSS v4.2, Alpine.js 3.14.8 (CDN) Pages: 10 EN (src/pages/) + 9 AR (src/pages/ar/) + 1 bilingual 404 Site: https://urwhats.com | App: https://app.urwhats.com


Task 1: Blog Infrastructure Setup

Install MDX support, create content collections, blog listing pages, post template, navigation entry, sitemap integration, and Article schema.

1.1 Install @astrojs/mdx

File: package.json

npm install @astrojs/mdx

This adds @astrojs/mdx to dependencies in package.json.

1.2 Add MDX integration to Astro config

File: astro.config.mjs

Before:

import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';

export default defineConfig({
    output: 'static',
    compressHTML: true,
    site: 'https://urwhats.com',
    trailingSlash: 'never',

After:

import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
import mdx from '@astrojs/mdx';

export default defineConfig({
    output: 'static',
    compressHTML: true,
    site: 'https://urwhats.com',
    trailingSlash: 'never',
    integrations: [mdx()],

No other changes to astro.config.mjs are needed. The rest of the file (devToolbar, prefetch, build, image, vite) stays identical.

1.3 Create content collection config

File: src/content/config.ts (NEW)

import { defineCollection, z } from 'astro:content';

const blog = defineCollection({
    type: 'content',
    schema: z.object({
        title: z.string(),
        description: z.string().max(160),
        date: z.coerce.date(),
        updatedDate: z.coerce.date().optional(),
        author: z.string().default('urWhats Team'),
        category: z.enum([
            'guides',
            'product-updates',
            'whatsapp-business',
            'case-studies',
            'industry',
            'tips',
        ]),
        tags: z.array(z.string()).default([]),
        lang: z.enum(['en', 'ar']),
        image: z.object({
            src: z.string(),
            alt: z.string(),
        }).optional(),
        draft: z.boolean().default(false),
    }),
});

export const collections = { blog };

1.4 Create content directories and placeholder post

Directories: src/content/blog/en/ and src/content/blog/ar/ (NEW)

File: src/content/blog/en/getting-started-whatsapp-business-api.mdx (NEW - placeholder)

---
title: "Getting Started with WhatsApp Business API in Saudi Arabia"
description: "A complete guide to setting up WhatsApp Business API for your Saudi business. Learn about Meta requirements, pricing, and best practices."
date: 2026-03-09
author: "urWhats Team"
category: "guides"
tags: ["whatsapp-api", "getting-started", "saudi-arabia"]
lang: "en"
image:
  src: "/assets/images/photos/dashboard-en.webp"
  alt: "urWhats Dashboard showing WhatsApp Business API setup"
---

# Getting Started with WhatsApp Business API in Saudi Arabia

This is a placeholder post. Replace with real content before publishing.

File: src/content/blog/ar/getting-started-whatsapp-business-api.mdx (NEW - placeholder)

---
title: "دليل البدء مع واجهة واتساب للأعمال API في السعودية"
description: "دليل شامل لإعداد واتساب للأعمال API لشركتك السعودية. تعرف على متطلبات Meta والأسعار وأفضل الممارسات."
date: 2026-03-09
author: "فريق urWhats"
category: "guides"
tags: ["واتساب-api", "البدء", "السعودية"]
lang: "ar"
image:
  src: "/assets/images/photos/dashboard-ar.webp"
  alt: "لوحة تحكم urWhats لإعداد واتساب للأعمال"
---

# دليل البدء مع واجهة واتساب للأعمال API في السعودية

هذا منشور مؤقت. استبدله بمحتوى حقيقي قبل النشر.

1.5 Create blog listing page (English)

File: src/pages/blog/index.astro (NEW)

---
import Layout from '../../layouts/Layout.astro';
import { useLanguagePage, getLink } from '../../config/page.config';
import Container from '../../components/ui/Container.astro';
import Section from '../../components/ui/Section.astro';
import SectionHeader from '../../components/ui/SectionHeader.astro';
import CTASection from '../../components/ui/CTASection.astro';
import { getCollection } from 'astro:content';

const { lang, t } = useLanguagePage(Astro);

const allPosts = await getCollection('blog', ({ data }) => {
    return data.lang === 'en' && !data.draft;
});

// Sort by date descending (newest first)
const posts = allPosts.sort(
    (a, b) => b.data.date.valueOf() - a.data.date.valueOf()
);

// Group by category for optional filtering
const categories = [...new Set(posts.map(p => p.data.category))];
---

<Layout lang={lang} title="Blog | urWhats">

    <Section bg="white" class="pt-28 sm:pt-36">
        <Container>
            <SectionHeader
                title="Blog"
                subtitle="Insights, guides, and updates on WhatsApp Business API"
            />

            {posts.length === 0 ? (
                <div class="text-center py-16">
                    <p class="text-neutral-500 text-lg">No posts yet. Check back soon.</p>
                </div>
            ) : (
                <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
                    {posts.map((post) => (
                        <article class="bg-white rounded-2xl border border-neutral-200 overflow-hidden hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
                            {post.data.image && (
                                <a href={`/blog/${post.slug}`}>
                                    <img
                                        src={post.data.image.src}
                                        alt={post.data.image.alt}
                                        class="w-full h-48 object-cover"
                                        width="400"
                                        height="192"
                                        loading="lazy"
                                    />
                                </a>
                            )}
                            <div class="p-6">
                                <div class="flex items-center gap-3 mb-3">
                                    <span class="text-xs font-semibold uppercase tracking-wider text-primary-600 bg-primary-50 px-2.5 py-1 rounded-full">
                                        {post.data.category.replace('-', ' ')}
                                    </span>
                                    <time
                                        datetime={post.data.date.toISOString()}
                                        class="text-xs text-neutral-400"
                                    >
                                        {post.data.date.toLocaleDateString('en-US', {
                                            year: 'numeric',
                                            month: 'short',
                                            day: 'numeric',
                                        })}
                                    </time>
                                </div>
                                <h2 class="text-lg font-semibold text-neutral-900 mb-2 line-clamp-2">
                                    <a href={`/blog/${post.slug}`} class="hover:text-primary-600 transition-colors">
                                        {post.data.title}
                                    </a>
                                </h2>
                                <p class="text-sm text-neutral-500 line-clamp-3 mb-4">
                                    {post.data.description}
                                </p>
                                <a
                                    href={`/blog/${post.slug}`}
                                    class="inline-flex items-center gap-1 text-sm font-semibold text-primary-600 hover:text-primary-700 transition-colors"
                                >
                                    Read more
                                    <svg class="w-4 h-4 rtl:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                                        <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
                                    </svg>
                                </a>
                            </div>
                        </article>
                    ))}
                </div>
            )}
        </Container>
    </Section>

    <CTASection
        title="Ready to grow your business with WhatsApp?"
        subtitle="Join hundreds of Saudi businesses using urWhats."
        primaryText="Get Started Free"
        primaryHref={getLink(lang, 'register')}
        secondaryText="View Plans"
        secondaryHref={getLink(lang, 'prices')}
    />
</Layout>

1.6 Create blog listing page (Arabic)

File: src/pages/ar/blog/index.astro (NEW)

---
import Layout from '../../../layouts/Layout.astro';
import { useLanguagePage, getLink } from '../../../config/page.config';
import Container from '../../../components/ui/Container.astro';
import Section from '../../../components/ui/Section.astro';
import SectionHeader from '../../../components/ui/SectionHeader.astro';
import CTASection from '../../../components/ui/CTASection.astro';
import { getCollection } from 'astro:content';

const { lang, t } = useLanguagePage(Astro);

const allPosts = await getCollection('blog', ({ data }) => {
    return data.lang === 'ar' && !data.draft;
});

const posts = allPosts.sort(
    (a, b) => b.data.date.valueOf() - a.data.date.valueOf()
);
---

<Layout lang={lang} title="المدونة | urWhats">

    <Section bg="white" class="pt-28 sm:pt-36">
        <Container>
            <SectionHeader
                title="المدونة"
                subtitle="مقالات وأدلة وتحديثات حول واتساب للأعمال API"
            />

            {posts.length === 0 ? (
                <div class="text-center py-16">
                    <p class="text-neutral-500 text-lg">لا توجد مقالات بعد. تابعنا قريباً.</p>
                </div>
            ) : (
                <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
                    {posts.map((post) => (
                        <article class="bg-white rounded-2xl border border-neutral-200 overflow-hidden hover:-translate-y-1 hover:shadow-xl transition-all duration-300">
                            {post.data.image && (
                                <a href={`/ar/blog/${post.slug}`}>
                                    <img
                                        src={post.data.image.src}
                                        alt={post.data.image.alt}
                                        class="w-full h-48 object-cover"
                                        width="400"
                                        height="192"
                                        loading="lazy"
                                    />
                                </a>
                            )}
                            <div class="p-6">
                                <div class="flex items-center gap-3 mb-3">
                                    <span class="text-xs font-semibold uppercase tracking-wider text-primary-600 bg-primary-50 px-2.5 py-1 rounded-full">
                                        {post.data.category.replace('-', ' ')}
                                    </span>
                                    <time
                                        datetime={post.data.date.toISOString()}
                                        class="text-xs text-neutral-400"
                                    >
                                        {post.data.date.toLocaleDateString('ar-SA', {
                                            year: 'numeric',
                                            month: 'short',
                                            day: 'numeric',
                                        })}
                                    </time>
                                </div>
                                <h2 class="text-lg font-semibold text-neutral-900 mb-2 line-clamp-2">
                                    <a href={`/ar/blog/${post.slug}`} class="hover:text-primary-600 transition-colors">
                                        {post.data.title}
                                    </a>
                                </h2>
                                <p class="text-sm text-neutral-500 line-clamp-3 mb-4">
                                    {post.data.description}
                                </p>
                                <a
                                    href={`/ar/blog/${post.slug}`}
                                    class="inline-flex items-center gap-1 text-sm font-semibold text-primary-600 hover:text-primary-700 transition-colors"
                                >
                                    اقرأ المزيد
                                    <svg class="w-4 h-4 rtl:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
                                        <path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
                                    </svg>
                                </a>
                            </div>
                        </article>
                    ))}
                </div>
            )}
        </Container>
    </Section>

    <CTASection
        title="مستعد لتنمية أعمالك عبر واتساب؟"
        subtitle="انضم لمئات الشركات السعودية التي تستخدم urWhats."
        primaryText="ابدأ مجاناً"
        primaryHref={getLink(lang, 'register')}
        secondaryText="عرض الباقات"
        secondaryHref={getLink(lang, 'prices')}
    />
</Layout>

1.7 Create blog post template (English)

File: src/pages/blog/[...slug].astro (NEW)

---
import Layout from '../../layouts/Layout.astro';
import { useLanguagePage, getLink } from '../../config/page.config';
import Container from '../../components/ui/Container.astro';
import CTASection from '../../components/ui/CTASection.astro';
import { getCollection } from 'astro:content';

const { lang, t } = useLanguagePage(Astro);

export async function getStaticPaths() {
    const posts = await getCollection('blog', ({ data }) => {
        return data.lang === 'en' && !data.draft;
    });
    return posts.map((post) => ({
        params: { slug: post.slug },
        props: { post },
    }));
}

const { post } = Astro.props;
const { Content } = await post.render();

const formattedDate = post.data.date.toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
});

// Find the Arabic mirror post for hreflang (same slug convention)
const arPosts = await getCollection('blog', ({ data }) => data.lang === 'ar');
const arMirror = arPosts.find(p => p.slug === post.slug);
---

<Layout lang={lang} title={`${post.data.title} | urWhats Blog`}>

    <article class="pt-28 sm:pt-36 pb-16">
        <Container narrow>
            <!-- Breadcrumb -->
            <nav class="mb-8 text-sm text-neutral-400" aria-label="Breadcrumb">
                <ol class="flex items-center gap-2">
                    <li><a href="/" class="hover:text-primary-600 transition-colors">Home</a></li>
                    <li>/</li>
                    <li><a href="/blog" class="hover:text-primary-600 transition-colors">Blog</a></li>
                    <li>/</li>
                    <li class="text-neutral-600 truncate max-w-[200px]">{post.data.title}</li>
                </ol>
            </nav>

            <!-- Article Header -->
            <header class="mb-10">
                <div class="flex items-center gap-3 mb-4">
                    <span class="text-xs font-semibold uppercase tracking-wider text-primary-600 bg-primary-50 px-2.5 py-1 rounded-full">
                        {post.data.category.replace('-', ' ')}
                    </span>
                    <time datetime={post.data.date.toISOString()} class="text-sm text-neutral-400">
                        {formattedDate}
                    </time>
                </div>
                <h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight text-neutral-900 mb-4">
                    {post.data.title}
                </h1>
                <p class="text-lg text-neutral-500">{post.data.description}</p>
                {post.data.author && (
                    <p class="mt-4 text-sm text-neutral-400">
                        By <span class="text-neutral-600 font-medium">{post.data.author}</span>
                    </p>
                )}
            </header>

            {/* Hero image */}
            {post.data.image && (
                <div class="rounded-2xl overflow-hidden mb-10 border border-neutral-200">
                    <img
                        src={post.data.image.src}
                        alt={post.data.image.alt}
                        class="w-full"
                        width="800"
                        height="400"
                        loading="eager"
                    />
                </div>
            )}

            {/* Article body */}
            <div class="prose prose-lg prose-neutral max-w-none
                         prose-headings:font-bold prose-headings:tracking-tight
                         prose-a:text-primary-600 prose-a:no-underline hover:prose-a:underline
                         prose-img:rounded-xl prose-img:border prose-img:border-neutral-200">
                <Content />
            </div>

            {/* Tags */}
            {post.data.tags.length > 0 && (
                <div class="mt-10 pt-6 border-t border-neutral-200">
                    <div class="flex flex-wrap gap-2">
                        {post.data.tags.map((tag: string) => (
                            <span class="text-xs font-medium text-neutral-500 bg-neutral-100 px-3 py-1.5 rounded-full">
                                #{tag}
                            </span>
                        ))}
                    </div>
                </div>
            )}
        </Container>
    </article>

    <CTASection
        title="Ready to get started?"
        subtitle="Join hundreds of Saudi businesses using urWhats."
        primaryText="Get Started Free"
        primaryHref={getLink(lang, 'register')}
        secondaryText="View Plans"
        secondaryHref={getLink(lang, 'prices')}
    />
</Layout>

1.8 Create blog post template (Arabic)

File: src/pages/ar/blog/[...slug].astro (NEW)

---
import Layout from '../../../layouts/Layout.astro';
import { useLanguagePage, getLink } from '../../../config/page.config';
import Container from '../../../components/ui/Container.astro';
import CTASection from '../../../components/ui/CTASection.astro';
import { getCollection } from 'astro:content';

const { lang, t } = useLanguagePage(Astro);

export async function getStaticPaths() {
    const posts = await getCollection('blog', ({ data }) => {
        return data.lang === 'ar' && !data.draft;
    });
    return posts.map((post) => ({
        params: { slug: post.slug },
        props: { post },
    }));
}

const { post } = Astro.props;
const { Content } = await post.render();

const formattedDate = post.data.date.toLocaleDateString('ar-SA', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
});
---

<Layout lang={lang} title={`${post.data.title} | مدونة urWhats`}>

    <article class="pt-28 sm:pt-36 pb-16">
        <Container narrow>
            <!-- Breadcrumb -->
            <nav class="mb-8 text-sm text-neutral-400" aria-label="مسار التنقل">
                <ol class="flex items-center gap-2">
                    <li><a href="/ar" class="hover:text-primary-600 transition-colors">الرئيسية</a></li>
                    <li>/</li>
                    <li><a href="/ar/blog" class="hover:text-primary-600 transition-colors">المدونة</a></li>
                    <li>/</li>
                    <li class="text-neutral-600 truncate max-w-[200px]">{post.data.title}</li>
                </ol>
            </nav>

            <header class="mb-10">
                <div class="flex items-center gap-3 mb-4">
                    <span class="text-xs font-semibold uppercase tracking-wider text-primary-600 bg-primary-50 px-2.5 py-1 rounded-full">
                        {post.data.category.replace('-', ' ')}
                    </span>
                    <time datetime={post.data.date.toISOString()} class="text-sm text-neutral-400">
                        {formattedDate}
                    </time>
                </div>
                <h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight text-neutral-900 mb-4">
                    {post.data.title}
                </h1>
                <p class="text-lg text-neutral-500">{post.data.description}</p>
                {post.data.author && (
                    <p class="mt-4 text-sm text-neutral-400">
                        بقلم <span class="text-neutral-600 font-medium">{post.data.author}</span>
                    </p>
                )}
            </header>

            {post.data.image && (
                <div class="rounded-2xl overflow-hidden mb-10 border border-neutral-200">
                    <img
                        src={post.data.image.src}
                        alt={post.data.image.alt}
                        class="w-full"
                        width="800"
                        height="400"
                        loading="eager"
                    />
                </div>
            )}

            <div class="prose prose-lg prose-neutral max-w-none
                         prose-headings:font-bold prose-headings:tracking-tight
                         prose-a:text-primary-600 prose-a:no-underline hover:prose-a:underline
                         prose-img:rounded-xl prose-img:border prose-img:border-neutral-200">
                <Content />
            </div>

            {post.data.tags.length > 0 && (
                <div class="mt-10 pt-6 border-t border-neutral-200">
                    <div class="flex flex-wrap gap-2">
                        {post.data.tags.map((tag: string) => (
                            <span class="text-xs font-medium text-neutral-500 bg-neutral-100 px-3 py-1.5 rounded-full">
                                #{tag}
                            </span>
                        ))}
                    </div>
                </div>
            )}
        </Container>
    </article>

    <CTASection
        title="مستعد للبدء؟"
        subtitle="انضم لمئات الشركات السعودية التي تستخدم urWhats."
        primaryText="ابدأ مجاناً"
        primaryHref={getLink(lang, 'register')}
        secondaryText="عرض الباقات"
        secondaryHref={getLink(lang, 'prices')}
    />
</Layout>

1.9 Add "Blog" to navigation

File: src/components/Navigation.astro

Find the navLinks array (line 24-32) and add the blog entry:

Before:

const navLinks = [
  { label: t('nav.home'),     href: getLink(lang, ''),            match: '' },
  { label: t('nav.features'),      href: getLink(lang, 'features'),      match: 'features' },
  { label: t('nav.solutions'),     href: getLink(lang, 'solutions'),     match: 'solutions' },
  { label: t('nav.pricing'),       href: getLink(lang, 'prices'),        match: 'prices' },
  { label: t('nav.integrations'),  href: getLink(lang, 'integrations'),  match: 'integrations' },
  { label: t('nav.faqs'),     href: getLink(lang, 'faqs'),        match: 'faqs' },
  { label: t('nav.contact'),  href: getLink(lang, 'contact'),     match: 'contact' },
];

After:

const navLinks = [
  { label: t('nav.home'),     href: getLink(lang, ''),            match: '' },
  { label: t('nav.features'),      href: getLink(lang, 'features'),      match: 'features' },
  { label: t('nav.solutions'),     href: getLink(lang, 'solutions'),     match: 'solutions' },
  { label: t('nav.pricing'),       href: getLink(lang, 'prices'),        match: 'prices' },
  { label: t('nav.integrations'),  href: getLink(lang, 'integrations'),  match: 'integrations' },
  { label: t('nav.blog'),     href: getLink(lang, 'blog'),        match: 'blog' },
  { label: t('nav.faqs'),     href: getLink(lang, 'faqs'),        match: 'faqs' },
  { label: t('nav.contact'),  href: getLink(lang, 'contact'),     match: 'contact' },
];

1.10 Add blog translation keys

File: public/assets/i18n/en.json

Add to the "navigation" object (alongside existing "nav.home", "nav.features", etc.):

"nav.blog": "Blog",

File: public/assets/i18n/ar.json

"nav.blog": "المدونة",

Also add meta keys for the blog listing page:

en.json - add inside the "meta" object:

"blog": {
    "title": "Blog | urWhats - WhatsApp Business API Insights & Guides",
    "description": "Expert guides, product updates, and insights on WhatsApp Business API, chatbots, and business automation in Saudi Arabia.",
    "keywords": "WhatsApp Business blog, WhatsApp API guides, business messaging tips, chatbot tutorials, Saudi Arabia WhatsApp"
}

ar.json - add inside the "meta" object:

"blog": {
    "title": "المدونة | urWhats - مقالات وأدلة واتساب للأعمال",
    "description": "أدلة متخصصة وتحديثات ومقالات حول واتساب للأعمال API وروبوتات المحادثة وأتمتة الأعمال في السعودية.",
    "keywords": "مدونة واتساب للأعمال, أدلة واتساب API, نصائح المراسلة, دروس روبوت المحادثة, واتساب السعودية"
}

1.11 Add blog pages to sitemap

The blog pages will be auto-discovered by Astro since they are static pages generated via getStaticPaths(). If you have a custom sitemap at src/pages/sitemap-index.xml.ts, add blog entries there.

If not using a custom sitemap generator, the static pages at /blog/* and /ar/blog/* will be included automatically when using @astrojs/sitemap.

For the custom sitemap, add this pattern for dynamically discovering blog posts:

File: src/pages/sitemap-index.xml.ts (if exists, modify; otherwise skip)

Add after existing page entries:

import { getCollection } from 'astro:content';

// Inside the function that generates sitemap entries:
const enPosts = await getCollection('blog', ({ data }) => data.lang === 'en' && !data.draft);
const arPosts = await getCollection('blog', ({ data }) => data.lang === 'ar' && !data.draft);

// Add to entries array:
for (const post of enPosts) {
    entries.push({
        url: `${site}/blog/${post.slug}`,
        lastmod: (post.data.updatedDate ?? post.data.date).toISOString(),
        changefreq: 'monthly',
        priority: 0.7,
        alternates: {
            en: `${site}/blog/${post.slug}`,
            ar: `${site}/ar/blog/${post.slug}`,
        },
    });
}

for (const post of arPosts) {
    entries.push({
        url: `${site}/ar/blog/${post.slug}`,
        lastmod: (post.data.updatedDate ?? post.data.date).toISOString(),
        changefreq: 'monthly',
        priority: 0.7,
        alternates: {
            en: `${site}/blog/${post.slug}`,
            ar: `${site}/ar/blog/${post.slug}`,
        },
    });
}

Acceptance Criteria - Task 1


Task 2: CTA Text Replacement

Replace all "Start Free Trial" with "Get Started Free" (EN) and all trial references with "ابدأ مجاناً" (AR). Update trial badge text.

2.1 Translation file changes

File: public/assets/i18n/en.json

Line Key Before After
140 home.hero.buttons.subscribe "Start Free Trial" "Get Started Free"
136 home.pricing.title (prefix) "Start your free trial" "Start growing with WhatsApp"

File: public/assets/i18n/ar.json

Update the matching keys. The Arabic CTA equivalent of "Get Started Free" is "ابدأ مجاناً". Ensure all instances of "ابدأ تجربة مجانية" and "ابدأ تجربتك المجانية" are changed to "ابدأ مجاناً".

2.2 Static plans config

File: src/config/static-plans.config.ts

Before (line 179):

startTrial:       { en: 'Start Free Trial',               ar: 'ابدأ تجربة مجانية' },

After:

startTrial:       { en: 'Get Started Free',                ar: 'ابدأ مجاناً' },

Before (line 188):

trialBadge:       { en: '7-day free trial',               ar: 'تجربة مجانية ٧ أيام' },

After:

trialBadge:       { en: '250 free messages \u2022 No credit card', ar: '٢٥٠ رسالة مجانية \u2022 بدون بطاقة ائتمان' },

2.3 Hardcoded CTA text in page files

Each file below has hardcoded "Start Free Trial" strings that must be replaced with "Get Started Free" (EN) and "ابدأ مجاناً" (AR):

File: src/pages/features.astro

Line 68 — change:

{lang === 'ar' ? 'ابدأ تجربتك المجانية' : 'Start Free Trial'}

to:

{lang === 'ar' ? 'ابدأ مجاناً' : 'Get Started Free'}

Line 225 — change primaryText:

primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Start Free Trial'}

to:

primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Get Started Free'}

File: src/pages/ar/features.astro — same changes at lines 68 and 225.

File: src/pages/solutions.astro

Line 205 — change:

primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Start Free Trial'}

to:

primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Get Started Free'}

File: src/pages/ar/solutions.astro — line 204, same change.

File: src/pages/integrations.astro

Line 172 — change:

{lang === 'ar' ? 'ابدأ مجانًا' : 'Start Free Trial'}

to:

{lang === 'ar' ? 'ابدأ مجاناً' : 'Get Started Free'}

Line 182 — change primaryText:

primaryText={lang === 'ar' ? 'ابدأ مجانًا' : 'Start Free Trial'}

to:

primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Get Started Free'}

File: src/pages/ar/integrations.astro — lines 171, 181, same changes.

File: src/pages/prices.astro

Line 28-29 — change subtitle and primaryText:

subtitle={lang === 'ar' ? 'ابدأ تجربتك المجانية لمدة 14 يومًا بدون بطاقة ائتمان' : 'Start your 14-day free trial. No credit card required.'}
primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Start Free Trial'}

to:

subtitle={lang === 'ar' ? '٢٥٠ رسالة مجانية للبدء. بدون بطاقة ائتمان.' : '250 free messages to get started. No credit card required.'}
primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Get Started Free'}

File: src/pages/ar/prices.astro — lines 27-28, same changes.

File: src/pages/faqs.astro

Line 52 — change:

secondaryText={lang === 'ar' ? 'ابدأ مجانًا' : 'Start Free Trial'}

to:

secondaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Get Started Free'}

File: src/pages/ar/faqs.astro — line 51, same change.

2.4 SEO.astro FAQ schema

File: src/components/SEO.astro

Lines 289-294 — update the free trial FAQ:

Before:

"name": lang === 'ar' ? "هل هناك فترة تجريبية مجانية؟" : "Is there a free trial available?",
"acceptedAnswer": {
    "@type": "Answer",
    "text": lang === 'ar'
      ? "نعم، نقدم فترة تجريبية مجانية حتى تتمكن من استكشاف منصتنا قبل الاشتراك"
      : "Yes, we offer a free trial so you can explore our platform before committing."
}

After:

"name": lang === 'ar' ? "هل يمكنني تجربة المنصة مجاناً؟" : "Can I try the platform for free?",
"acceptedAnswer": {
    "@type": "Answer",
    "text": lang === 'ar'
      ? "نعم، نقدم ٢٥٠ رسالة مجانية للبدء بدون الحاجة لبطاقة ائتمان."
      : "Yes, you get 250 free messages to get started — no credit card required."
}

2.5 Meta description updates

File: public/assets/i18n/en.json

Line 5 (home meta description) — replace "Start your free trial." with "Get started free with 250 messages.":

Before:

"description": "Official Meta Technical Provider in Saudi Arabia. WhatsApp Business API platform for bulk messaging, AI chatbots, and CRM integration. SAR pricing, Arabic-first support, Salla integration. Start your free trial.",

After:

"description": "Official Meta Technical Provider in Saudi Arabia. WhatsApp Business API platform for bulk messaging, AI chatbots, and CRM integration. SAR pricing, Arabic-first support, Salla integration. Get started free with 250 messages.",

Line 60 (features meta description) — same replacement.

Line 390 (pricing meta description):

Before:

"description": "Transparent SAR pricing with no hidden fees. Every plan includes the official Meta WhatsApp API, 7-day free trial, and dedicated support."

After:

"description": "Transparent SAR pricing with no hidden fees. Every plan includes the official Meta WhatsApp API, 250 free messages, and dedicated support."

Summary of all files touched

File Changes
public/assets/i18n/en.json ~5 key value updates
public/assets/i18n/ar.json ~5 matching key value updates
src/config/static-plans.config.ts startTrial label + trialBadge text
src/pages/features.astro 2 CTA strings
src/pages/ar/features.astro 2 CTA strings
src/pages/solutions.astro 1 CTA string
src/pages/ar/solutions.astro 1 CTA string
src/pages/integrations.astro 2 CTA strings
src/pages/ar/integrations.astro 2 CTA strings
src/pages/prices.astro 2 CTA strings (subtitle + primaryText)
src/pages/ar/prices.astro 2 CTA strings
src/pages/faqs.astro 1 CTA string
src/pages/ar/faqs.astro 1 CTA string
src/components/SEO.astro 1 FAQ schema Q&A

Total: ~25 changes across 14 files.

Acceptance Criteria - Task 2


Task 3: Free Tier Addition to Pricing

Add a Free plan (0 SAR) to the pricing grid alongside the existing 4 plans.

3.1 Add Free plan to static config

File: src/config/static-plans.config.ts

Add as the first element in the plans array (before Starter):

// ── Free ──
{
    id: 'free',
    nameEn: 'Free',
    nameAr: 'مجانية',
    descriptionEn: 'Try urWhats with no commitment',
    descriptionAr: 'جرّب urWhats بدون أي التزام',
    pricing: {
        monthly: { sar: 0, usd: 0 },
        yearly:  { sar: 0, usd: 0 },
    },
    isRecommended: false,
    isContactUs: false,
    ctaType: 'trial',
    features: [
        { key: 'whatsapp_numbers', en: '1 WhatsApp Number',      ar: 'رقم واتساب واحد',            included: true },
        { key: 'users',            en: '1 User',                  ar: 'مستخدم واحد',                included: true },
        { key: 'contacts',         en: '250 Contacts',            ar: '٢٥٠ جهة اتصال',             included: true },
        { key: 'messages',         en: '250 Messages / month',    ar: '٢٥٠ رسالة / شهرياً',         included: true },
        { key: 'chatbot',          en: 'Basic Chatbot',           ar: 'روبوت محادثة أساسي',         included: true },
        { key: 'support',          en: 'Community Support',       ar: 'دعم مجتمعي',                included: true },
    ],
},

3.2 Add Free plan CTA URL

File: src/components/DynamicPlans.astro

Add to the planCtaUrls object (line 29-34):

Before:

const planCtaUrls: Record<string, string> = {
    starter: `${registerBase}?plan=3`,
    growth: `${registerBase}?plan=1`,
    pro: `${registerBase}?plan=2`,
    enterprise: getLink(lang, 'contact'),
};

After:

const planCtaUrls: Record<string, string> = {
    free: registerBase,
    starter: `${registerBase}?plan=3`,
    growth: `${registerBase}?plan=1`,
    pro: `${registerBase}?plan=2`,
    enterprise: getLink(lang, 'contact'),
};

3.3 Update pricing grid layout for 5 columns

File: src/components/DynamicPlans.astro

The current grid is grid-cols-1 md:grid-cols-2 xl:grid-cols-4. With 5 plans, change to allow 5 columns on wide screens or keep 4 columns with wrapping.

Option A: 5-column layout (recommended for desktop)

Change line 128:

Before:

<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6 items-stretch">

After:

<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-5 items-stretch">

Also reduce card padding slightly for the 5-col layout. Change the card container (line 130):

Before:

<div class={`rounded-2xl p-6 sm:p-8 border hover:border-primary-300 ...`}>

After:

<div class={`rounded-2xl p-5 sm:p-6 border hover:border-primary-300 ...`}>

3.4 Handle Free plan price display

The existing Alpine.js getPrice() already handles 0 prices by returning "0" via toLocaleString(). However, we want to display "Free" / "مجاني" instead of "SAR 0".

File: src/components/DynamicPlans.astro

Update the Alpine getPrice function in the x-data block (line 42-47):

Before:

getPrice(planId) {
    const p = this.plans.find(x => x.id === planId);
    if (!p || p.isContactUs) return '';
    const val = p[this.currency][this.duration];
    return val.toLocaleString();
},

After:

getPrice(planId) {
    const p = this.plans.find(x => x.id === planId);
    if (!p || p.isContactUs) return '';
    const val = p[this.currency][this.duration];
    if (val === 0) return '${lang === 'ar' ? 'مجاني' : 'Free'}';
    return val.toLocaleString();
},

Also hide the currency symbol and period label when price is 0. Update the price display section for non-contact plans (around line 151-155):

Before:

<div class="flex items-baseline justify-center gap-1" dir="ltr" aria-live="polite">
    <span class="text-base font-semibold text-neutral-500" x-text="getCurrencySymbol()"></span>
    <span class="text-4xl font-bold text-neutral-900" x-text={`getPrice('${plan.id}')`}></span>
    <span class="text-sm text-neutral-500" x-text="getPeriodLabel()"></span>
</div>

After:

<div class="flex items-baseline justify-center gap-1" dir="ltr" aria-live="polite">
    <template x-if={`!plans.find(x => x.id === '${plan.id}') || plans.find(x => x.id === '${plan.id}')[currency][duration] !== 0`}>
        <span class="text-base font-semibold text-neutral-500" x-text="getCurrencySymbol()"></span>
    </template>
    <span class="text-4xl font-bold text-neutral-900" x-text={`getPrice('${plan.id}')`}></span>
    <template x-if={`!plans.find(x => x.id === '${plan.id}') || plans.find(x => x.id === '${plan.id}')[currency][duration] !== 0`}>
        <span class="text-sm text-neutral-500" x-text="getPeriodLabel()"></span>
    </template>
</div>

Simpler alternative: Since the Free plan has id: 'free' and always has 0 price, use a direct check:

<div class="flex items-baseline justify-center gap-1" dir="ltr" aria-live="polite">
    {plan.id !== 'free' && (
        <span class="text-base font-semibold text-neutral-500" x-text="getCurrencySymbol()"></span>
    )}
    <span class="text-4xl font-bold text-neutral-900" x-text={`getPrice('${plan.id}')`}></span>
    {plan.id !== 'free' && (
        <span class="text-sm text-neutral-500" x-text="getPeriodLabel()"></span>
    )}
</div>

This simpler approach is preferred since plan.id is known at build time.

3.5 Update SoftwareApplication schema

File: src/components/SEO.astro

Update the offers in softwareSchema (line 155-161):

Before:

"offers": {
    "@type": "AggregateOffer",
    "lowPrice": "349",
    "highPrice": "1999",
    "priceCurrency": "SAR",
    "offerCount": "4"
},

After:

"offers": {
    "@type": "AggregateOffer",
    "lowPrice": "0",
    "highPrice": "1999",
    "priceCurrency": "SAR",
    "offerCount": "5"
},

Acceptance Criteria - Task 3


Task 4: Tawk.to Live Chat Integration

Add Tawk.to widget with language-aware configuration and CSP headers.

4.1 Add Tawk.to script to Layout

File: src/layouts/Layout.astro

Add new environment variable reads at the top of the frontmatter (after line 12):

const tawkPropertyId = import.meta.env.PUBLIC_TAWK_PROPERTY_ID;
const tawkWidgetIdEn = import.meta.env.PUBLIC_TAWK_WIDGET_ID_EN;
const tawkWidgetIdAr = import.meta.env.PUBLIC_TAWK_WIDGET_ID_AR;
const tawkWidgetId = lang === 'ar' ? tawkWidgetIdAr : tawkWidgetIdEn;

Add the Tawk.to script before the closing </body> tag (before line 118), after the scroll animations script and before </body>:

    {/* Tawk.to Live Chat */}
    {tawkPropertyId && tawkWidgetId && (
        <script define:vars={{ tawkPropertyId, tawkWidgetId }}>
            var Tawk_API = Tawk_API || {};
            var Tawk_LoadStart = new Date();
            (function(){
                var s1 = document.createElement("script");
                var s0 = document.getElementsByTagName("script")[0];
                s1.async = true;
                s1.src = 'https://embed.tawk.to/' + tawkPropertyId + '/' + tawkWidgetId;
                s1.charset = 'UTF-8';
                s1.setAttribute('crossorigin', '*');
                s0.parentNode.insertBefore(s1, s0);
            })();
        </script>
    )}
</body>

Important: The script only loads when both env vars are set. In development without env vars, no Tawk.to widget appears.

4.2 Update CSP headers

File: public/_headers

Update the Content-Security-Policy header (line 6). Add Tawk.to domains to the relevant directives:

Current CSP (single line, key sections shown):

script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com ... https://connect.facebook.net;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
connect-src 'self' https://www.googletagmanager.com ... https://connect.facebook.net;
frame-src https://challenges.cloudflare.com https://www.facebook.com;
img-src 'self' data: https: blob:;

Add the following domains to each directive:

Directive Add
script-src https://embed.tawk.to
style-src (no change needed -- Tawk injects inline styles, already covered by 'unsafe-inline')
connect-src https://va.tawk.to https://embed.tawk.to
frame-src https://tawk.to https://*.tawk.to
img-src (no change needed -- already has https:)
font-src (no change needed -- already has data:)

Updated CSP line (full replacement for line 6):

  Content-Security-Policy: default-src 'self'; img-src 'self' data: https: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://googletagmanager.com https://www.google-analytics.com https://static.cloudflareinsights.com https://challenges.cloudflare.com https://cdn.jsdelivr.net https://connect.facebook.net https://embed.tawk.to; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://www.googletagmanager.com https://www.google-analytics.com https://static.cloudflareinsights.com https://challenges.cloudflare.com https://formspree.io https://api.urwhats.com https://app.urwhats.com https://www.facebook.com https://connect.facebook.net https://va.tawk.to https://embed.tawk.to; frame-src https://challenges.cloudflare.com https://www.facebook.com https://tawk.to https://*.tawk.to;

4.3 Update TypeScript env declarations

File: src/env.d.ts

Add the new env variables:

Before:

interface ImportMetaEnv {
  readonly PUBLIC_SITE_URL: string;
  readonly PUBLIC_FORMSPARK_FORM_ID: string;
  readonly PUBLIC_TURNSTILE_SITE_KEY: string;
  readonly PUBLIC_GTM_ID?: string;
  readonly PUBLIC_REGISTRATION_API_KEY?: string;
}

After:

interface ImportMetaEnv {
  readonly PUBLIC_SITE_URL: string;
  readonly PUBLIC_FORMSPARK_FORM_ID: string;
  readonly PUBLIC_TURNSTILE_SITE_KEY: string;
  readonly PUBLIC_GTM_ID?: string;
  readonly PUBLIC_REGISTRATION_API_KEY?: string;
  readonly PUBLIC_TAWK_PROPERTY_ID?: string;
  readonly PUBLIC_TAWK_WIDGET_ID_EN?: string;
  readonly PUBLIC_TAWK_WIDGET_ID_AR?: string;
}

Acceptance Criteria - Task 4


Task 5: VideoSection.astro Component

Create a reusable YouTube embed component with poster image, play button overlay, and privacy-enhanced mode.

5.1 Create the component

File: src/components/VideoSection.astro (NEW)

---
/**
 * VideoSection - YouTube embed with poster image and play overlay.
 * Uses youtube-nocookie.com for privacy-enhanced mode.
 * Lazy-loads the iframe only when user clicks play.
 *
 * Props:
 *   videoId   - YouTube video ID (e.g., "dQw4w9WgXcQ")
 *   posterSrc - Path to poster image (optional, falls back to YouTube thumbnail)
 *   posterAlt - Alt text for poster image
 *   lang      - 'en' | 'ar' for button labels
 *   title     - Section title (optional)
 *   subtitle  - Section subtitle (optional)
 */
interface Props {
    videoId: string;
    posterSrc?: string;
    posterAlt?: string;
    lang: 'en' | 'ar';
    title?: string;
    subtitle?: string;
}

const {
    videoId,
    posterSrc,
    posterAlt = 'Video thumbnail',
    lang,
    title,
    subtitle,
} = Astro.props;

const thumbnailUrl = posterSrc || `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`;
const iframeSrc = `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&rel=0&modestbranding=1`;
const playLabel = lang === 'ar' ? 'تشغيل الفيديو' : 'Play video';
---

<section class="py-12 sm:py-16">
    <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
        {(title || subtitle) && (
            <div class="text-center mb-10 max-w-3xl mx-auto">
                {title && <h2 class="text-3xl sm:text-4xl font-bold text-neutral-900 mb-3">{title}</h2>}
                {subtitle && <p class="text-lg text-neutral-500">{subtitle}</p>}
            </div>
        )}

        <div
            class="relative max-w-4xl mx-auto rounded-2xl overflow-hidden shadow-xl border border-neutral-200 bg-neutral-900"
            x-data="{ playing: false }"
        >
            {/* Poster + play button (shown before click) */}
            <div
                x-show="!playing"
                class="relative cursor-pointer group"
                @click="playing = true"
                role="button"
                tabindex="0"
                :aria-label={`'${playLabel}'`}
                @keydown.enter="playing = true"
                @keydown.space.prevent="playing = true"
            >
                {/* 16:9 aspect ratio container */}
                <div class="relative w-full" style="padding-bottom: 56.25%;">
                    <img
                        src={thumbnailUrl}
                        alt={posterAlt}
                        class="absolute inset-0 w-full h-full object-cover"
                        width="1280"
                        height="720"
                        loading="lazy"
                    />
                    {/* Dark overlay */}
                    <div class="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors duration-300"></div>
                    {/* Play button */}
                    <div class="absolute inset-0 flex items-center justify-center">
                        <div class="w-16 h-16 sm:w-20 sm:h-20 bg-white/90 group-hover:bg-white rounded-full flex items-center justify-center shadow-2xl transition-all duration-300 group-hover:scale-110">
                            <svg class="w-7 h-7 sm:w-8 sm:h-8 text-primary-600 ms-1" fill="currentColor" viewBox="0 0 24 24">
                                <path d="M8 5v14l11-7z" />
                            </svg>
                        </div>
                    </div>
                </div>
            </div>

            {/* Iframe (loaded on click) */}
            <template x-if="playing">
                <div class="relative w-full" style="padding-bottom: 56.25%;">
                    <iframe
                        class="absolute inset-0 w-full h-full"
                        src={iframeSrc}
                        title={posterAlt}
                        frameborder="0"
                        allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
                        allowfullscreen
                    ></iframe>
                </div>
            </template>
        </div>
    </div>
</section>

5.2 Usage on homepage

File: src/pages/index.astro

Add import at top of frontmatter:

import VideoSection from '../components/VideoSection.astro';

Place between the "Why Choose + Features" section and the "Pricing CTA" section (between lines 227 and 229):

  <!-- Product Demo Video -->
  <VideoSection
    videoId="REPLACE_WITH_ACTUAL_VIDEO_ID"
    lang={lang}
    title={lang === 'ar' ? 'شاهد المنصة أثناء العمل' : 'See the Platform in Action'}
    subtitle={lang === 'ar' ? 'اكتشف كيف يمكن لـ urWhats تحويل تواصلك مع العملاء' : 'Discover how urWhats can transform your customer communication'}
  />

File: src/pages/ar/index.astro — same addition at the equivalent position.

5.3 Update CSP for YouTube

File: public/_headers

Add https://www.youtube-nocookie.com and https://img.youtube.com to the CSP:

Directive Add
frame-src https://www.youtube-nocookie.com
img-src (already covered by https:)

Update the frame-src in the CSP line to include:

frame-src https://challenges.cloudflare.com https://www.facebook.com https://tawk.to https://*.tawk.to https://www.youtube-nocookie.com;

Acceptance Criteria - Task 5


Task 6: GTM DataLayer Events

Document and implement GTM dataLayer.push events for key conversion actions.

6.1 Registration complete event

This event fires on the app (app.urwhats.com), not on the marketing site. Document for the app team:

GTM Tag: Custom Event — registration_complete

// Fires on app.urwhats.com after successful registration
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({
    event: 'registration_complete',
    method: 'email',           // or 'google', 'phone'
    plan_name: 'starter',      // plan selected during registration
    plan_id: '3',
    currency: 'SAR',
});

6.2 Plan selected event

File: src/components/DynamicPlans.astro

Add a data-plan-name attribute to CTA buttons and fire a dataLayer event on click. Modify each CTA <a> tag (lines 173-187):

Add @click handler to all plan CTA links. The simplest approach is adding a click handler via a script at the bottom of the component.

Add before the closing </div> of the component (before line 240):

<script>
    document.querySelectorAll('[data-plan-id]').forEach((el) => {
        el.addEventListener('click', () => {
            const planId = el.getAttribute('data-plan-id');
            const planName = el.getAttribute('data-plan-name');
            window.dataLayer = window.dataLayer || [];
            window.dataLayer.push({
                event: 'plan_selected',
                plan_id: planId,
                plan_name: planName,
            });
        });
    });
</script>

Then add data-plan-id and data-plan-name attributes to each CTA button. Modify the three <a> blocks (contact, trial, subscribe) to include:

data-plan-id={plan.id}
data-plan-name={L({ en: plan.nameEn, ar: plan.nameAr })}

For example, the trial CTA (line 177-181) becomes:

<a href={planCtaUrls[plan.id]}
   data-plan-id={plan.id}
   data-plan-name={L({ en: plan.nameEn, ar: plan.nameAr })}
   class="block w-full text-center py-3 px-6 rounded-full font-semibold text-sm transition-all border-2 border-primary-500 text-primary-600 hover:bg-primary-500 hover:text-white">
    {L(pricingLabels.startTrial)}
</a>

Apply the same data-plan-id and data-plan-name to all three CTA variants (contact, trial, subscribe).

6.3 Language switch event

File: src/components/Navigation.astro

The existing language switch script (lines 296-303) already handles click events. Extend it to push a dataLayer event:

Before:

<script>
  document.querySelectorAll<HTMLAnchorElement>('[data-lang-switch]').forEach((link) => {
    link.addEventListener('click', () => {
      const lang = link.dataset.langSwitch;
      document.cookie = `lang=${lang}; path=/; max-age=2592000; SameSite=Lax`;
    });
  });
</script>

After:

<script>
  document.querySelectorAll<HTMLAnchorElement>('[data-lang-switch]').forEach((link) => {
    link.addEventListener('click', () => {
      const lang = link.dataset.langSwitch;
      document.cookie = `lang=${lang}; path=/; max-age=2592000; SameSite=Lax`;
      window.dataLayer = window.dataLayer || [];
      window.dataLayer.push({
        event: 'language_switch',
        new_language: lang,
      });
    });
  });
</script>

6.4 GTM cross-domain linker setup

This is a GTM container configuration, not a code change. Document for the GTM admin:

GTM Configuration — Cross-Domain Tracking

  1. In GTM, go to Tags > Google Tag (GA4 Configuration)
  2. Under Configuration Settings, add parameter:
    • Parameter: linker
    • Value: { "domains": ["urwhats.com", "app.urwhats.com"] }
  3. Or use the Cross Domain feature in GA4 Admin:
    • Go to GA4 Admin > Data Streams > Web > Configure Tag Settings > Configure Your Domains
    • Add: urwhats.com and app.urwhats.com
  4. This ensures the _gl parameter is appended when navigating between the marketing site and the app.

GTM Triggers to create:

Trigger Name Type Fires on
CE - plan_selected Custom Event Event name = plan_selected
CE - language_switch Custom Event Event name = language_switch
CE - registration_complete Custom Event Event name = registration_complete

GTM Variables to create (Data Layer):

Variable Name Data Layer Variable Name
dlv - plan_id plan_id
dlv - plan_name plan_name
dlv - new_language new_language
dlv - method method
dlv - currency currency

Acceptance Criteria - Task 6


Task 7: Blog SEO Infrastructure

Add ArticleSchema to SEO.astro for blog posts, with author, dates, and og:type override.

7.1 Accept blog post props in SEO.astro

File: src/components/SEO.astro

The SEO component needs to detect blog posts and render Article schema. Add optional props for blog post data.

Add after line 6 (const contactEmail = ...):

// Blog post data (passed from blog post template pages)
interface BlogPostData {
    title: string;
    description: string;
    date: Date;
    updatedDate?: Date;
    author: string;
    category: string;
    tags: string[];
    image?: { src: string; alt: string };
}

const blogPost: BlogPostData | undefined = Astro.props.blogPost;
const isBlogPost = !!blogPost;

7.2 Override og:type for blog posts

File: src/components/SEO.astro

Change the type variable (line 12):

Before:

const type = 'website';

After:

const type = isBlogPost ? 'article' : 'website';

7.3 Add article-specific OG meta tags

File: src/components/SEO.astro

Add after the existing Open Graph tags (after line 401, after og:site_name):

{/* Article-specific Open Graph tags */}
{isBlogPost && blogPost && (
    <>
        <meta property="article:published_time" content={blogPost.date.toISOString()} />
        {blogPost.updatedDate && (
            <meta property="article:modified_time" content={blogPost.updatedDate.toISOString()} />
        )}
        <meta property="article:author" content={blogPost.author} />
        <meta property="article:section" content={blogPost.category} />
        {blogPost.tags.map((tag: string) => (
            <meta property="article:tag" content={tag} />
        ))}
    </>
)}

7.4 Add Article schema to JSON-LD graph

File: src/components/SEO.astro

Add after the faqSchema definition (after line 298) and before the webPageSchema:

// Article Schema (only for blog posts)
const articleSchema = isBlogPost && blogPost ? {
    "@type": "Article",
    "@id": `${canonicalURL}#article`,
    "headline": blogPost.title,
    "description": blogPost.description,
    "image": blogPost.image
        ? new URL(blogPost.image.src, siteUrl).toString()
        : socialImageURL.toString(),
    "datePublished": blogPost.date.toISOString(),
    "dateModified": (blogPost.updatedDate ?? blogPost.date).toISOString(),
    "author": {
        "@type": "Person",
        "name": blogPost.author,
    },
    "publisher": { "@id": `${siteUrl}/#organization` },
    "mainEntityOfPage": { "@id": canonicalURL.toString() },
    "isPartOf": { "@id": `${siteUrl}/#website` },
    "inLanguage": lang === 'ar' ? "ar-SA" : "en-US",
    "articleSection": blogPost.category,
    "keywords": blogPost.tags.join(', '),
} : null;

Then add it to the @graph array (line 322-330):

Before:

const graphJsonLd = {
  "@context": "https://schema.org",
  "@graph": [
    organizationSchema,
    websiteSchema,
    softwareSchema,
    serviceSchema,
    breadcrumbSchema,
    webPageSchema,
    ...(faqSchema ? [faqSchema] : [])
  ]
};

After:

const graphJsonLd = {
  "@context": "https://schema.org",
  "@graph": [
    organizationSchema,
    websiteSchema,
    ...(isBlogPost ? [] : [softwareSchema, serviceSchema]),
    breadcrumbSchema,
    webPageSchema,
    ...(faqSchema ? [faqSchema] : []),
    ...(articleSchema ? [articleSchema] : []),
  ]
};

Note: softwareSchema and serviceSchema are excluded from blog post pages since they are not relevant.

7.5 Pass blogPost prop from blog post templates

File: src/pages/blog/[...slug].astro

Update the Layout usage to pass blogPost data:

<Layout lang={lang} title={`${post.data.title} | urWhats Blog`} blogPost={post.data}>

File: src/pages/ar/blog/[...slug].astro — same change:

<Layout lang={lang} title={`${post.data.title} | مدونة urWhats`} blogPost={post.data}>

7.6 Pass blogPost through Layout to SEO

File: src/layouts/Layout.astro

Update props destructuring (line 10):

Before:

const { lang, title } = Astro.props;

After:

const { lang, title, blogPost } = Astro.props;

Update the SEO component usage (line 21):

Before:

<SEO lang={lang} title={title}/>

After:

<SEO lang={lang} title={title} blogPost={blogPost}/>

Acceptance Criteria - Task 7


Task 8: Content Updates

Update homepage CTA section text, pricing page supporting text, and ensure consistent CTA messaging across all pages.

8.1 Homepage pricing CTA section

File: public/assets/i18n/en.json

Update the home pricing section keys used by the CTASection on the homepage:

Find and update these keys:

"home.pricing.title": "Ready to Transform Your WhatsApp Communication?"

Change to:

"home.pricing.title": "Start Growing with WhatsApp Today"
"home.pricing.subtitle": "..."

Change to:

"home.pricing.subtitle": "250 free messages. No credit card. Set up in 5 minutes."

File: public/assets/i18n/ar.json

Matching updates:

"home.pricing.title": "ابدأ بتنمية أعمالك عبر واتساب اليوم"
"home.pricing.subtitle": "٢٥٠ رسالة مجانية. بدون بطاقة ائتمان. إعداد في ٥ دقائق."

8.2 Pricing page CTA section

Files: src/pages/prices.astro and src/pages/ar/prices.astro

Both files have a CTASection at the bottom. Update the title and subtitle props:

Before (prices.astro, around line 26-30):

<CTASection
    title={lang === 'ar' ? '...' : '...'}
    subtitle={lang === 'ar' ? 'ابدأ تجربتك المجانية لمدة 14 يومًا بدون بطاقة ائتمان' : 'Start your 14-day free trial. No credit card required.'}
    primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Start Free Trial'}
    ...

After:

<CTASection
    title={lang === 'ar' ? 'ابدأ مجاناً اليوم' : 'Get Started Free Today'}
    subtitle={lang === 'ar' ? '٢٥٠ رسالة مجانية للبدء. بدون بطاقة ائتمان.' : '250 free messages to get started. No credit card required.'}
    primaryText={lang === 'ar' ? 'ابدأ مجاناً' : 'Get Started Free'}
    ...

8.3 Consistent CTA supporting text pattern

Establish a standard pattern for all CTASection components across all pages:

Standard EN supporting text options:

Standard AR supporting text options:

Apply to all pages that have a CTASection:

Page subtitle (use)
index.astro Primary
features.astro Primary
solutions.astro Secondary
prices.astro Primary
integrations.astro Primary
faqs.astro Secondary

Update each file's CTASection subtitle prop accordingly.

Acceptance Criteria - Task 8


Task 9: Environment Variables Documentation

Add Tawk.to env vars to .env.example and env.d.ts.

9.1 Update .env.example

File: .env.example

Before:

# Site URL (required)
PUBLIC_SITE_URL=https://urwhats.com

# Google Tag Manager (optional)
PUBLIC_GTM_ID=

# Formspree (Contact Form)
PUBLIC_FORMSPARK_FORM_ID=

# Cloudflare Turnstile (CAPTCHA)
PUBLIC_TURNSTILE_SITE_KEY=

After:

# Site URL (required)
PUBLIC_SITE_URL=https://urwhats.com

# Google Tag Manager (optional)
PUBLIC_GTM_ID=

# Formspree (Contact Form)
PUBLIC_FORMSPARK_FORM_ID=

# Cloudflare Turnstile (CAPTCHA)
PUBLIC_TURNSTILE_SITE_KEY=

# Tawk.to Live Chat (optional)
# Create widgets at https://dashboard.tawk.to — one EN, one AR
PUBLIC_TAWK_PROPERTY_ID=
PUBLIC_TAWK_WIDGET_ID_EN=
PUBLIC_TAWK_WIDGET_ID_AR=

9.2 Verify env.d.ts (already done in Task 4.3)

File: src/env.d.ts

Confirm these lines are present (added in Task 4.3):

readonly PUBLIC_TAWK_PROPERTY_ID?: string;
readonly PUBLIC_TAWK_WIDGET_ID_EN?: string;
readonly PUBLIC_TAWK_WIDGET_ID_AR?: string;

9.3 Document Cloudflare Pages env var setup

For deployment, set these environment variables in the Cloudflare Pages dashboard:

Variable Required Description
PUBLIC_SITE_URL Yes https://urwhats.com
PUBLIC_GTM_ID No Google Tag Manager container ID
PUBLIC_FORMSPARK_FORM_ID Yes Formspree form ID for contact form
PUBLIC_TURNSTILE_SITE_KEY Yes Cloudflare Turnstile CAPTCHA key
PUBLIC_TAWK_PROPERTY_ID No Tawk.to property ID (from dashboard)
PUBLIC_TAWK_WIDGET_ID_EN No Tawk.to English widget ID
PUBLIC_TAWK_WIDGET_ID_AR No Tawk.to Arabic widget ID

How to find Tawk.to IDs:

  1. Log into https://dashboard.tawk.to
  2. Go to Settings > Chat Widget
  3. The embed code contains: https://embed.tawk.to/{PROPERTY_ID}/{WIDGET_ID}
  4. Create two widgets: one for English, one for Arabic
  5. Set the property ID and both widget IDs in Cloudflare Pages env vars

Acceptance Criteria - Task 9


Execution Order

Tasks can be executed in dependency order:

Task 1 (Blog Infrastructure)  ─── depends on nothing
Task 2 (CTA Text Replacement) ─── depends on nothing
Task 3 (Free Tier)            ─── depends on nothing
Task 4 (Tawk.to)              ─── depends on nothing
Task 5 (VideoSection)         ─── depends on nothing
Task 6 (GTM DataLayer)        ─── depends on nothing
Task 7 (Blog SEO)             ─── depends on Task 1 (blog infrastructure must exist)
Task 8 (Content Updates)      ─── depends on Task 2 (CTA text changes)
Task 9 (Env Variables)        ─── depends on Task 4 (Tawk.to env vars)

Recommended batch execution:

  1. Batch 1 (parallel): Tasks 1, 2, 3, 4, 5, 6
  2. Batch 2 (sequential): Tasks 7, 8, 9

After all tasks: run npm run clean && npm run build to verify 0 errors across all 22+ pages.


Files Index

All files created or modified across all 9 tasks:

File Tasks Action
package.json 1 Modify (add @astrojs/mdx)
astro.config.mjs 1 Modify (add mdx integration)
src/content/config.ts 1 Create
src/content/blog/en/*.mdx 1 Create
src/content/blog/ar/*.mdx 1 Create
src/pages/blog/index.astro 1 Create
src/pages/ar/blog/index.astro 1 Create
src/pages/blog/[...slug].astro 1, 7 Create
src/pages/ar/blog/[...slug].astro 1, 7 Create
src/components/Navigation.astro 1, 6 Modify
src/components/SEO.astro 2, 3, 7 Modify
src/components/DynamicPlans.astro 3, 6 Modify
src/components/VideoSection.astro 5 Create
src/config/static-plans.config.ts 2, 3 Modify
src/layouts/Layout.astro 4, 7 Modify
src/env.d.ts 4, 9 Modify
src/pages/index.astro 5 Modify
src/pages/ar/index.astro 5 Modify
src/pages/features.astro 2 Modify
src/pages/ar/features.astro 2 Modify
src/pages/solutions.astro 2 Modify
src/pages/ar/solutions.astro 2 Modify
src/pages/integrations.astro 2 Modify
src/pages/ar/integrations.astro 2 Modify
src/pages/prices.astro 2, 8 Modify
src/pages/ar/prices.astro 2, 8 Modify
src/pages/faqs.astro 2 Modify
src/pages/ar/faqs.astro 2 Modify
public/assets/i18n/en.json 1, 2, 8 Modify
public/assets/i18n/ar.json 1, 2, 8 Modify
public/_headers 4, 5 Modify
.env.example 9 Modify