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
-
npm installsucceeds with @astrojs/mdx added -
npm run buildsucceeds with 0 errors -
/blogrenders the English blog listing page -
/ar/blogrenders the Arabic blog listing page -
/blog/getting-started-whatsapp-business-apirenders the placeholder post -
/ar/blog/getting-started-whatsapp-business-apirenders the Arabic placeholder post - "Blog" / "المدونة" appears in navigation on all pages
- Blog listing shows correct date formatting per language (en-US / ar-SA)
- Content collection schema validates frontmatter (try invalid category to confirm)
- Draft posts (
draft: true) are excluded from listings and builds - Blog post pages include breadcrumb navigation
- Blog pages include CTA section at bottom
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
- Zero instances of "Start Free Trial" remain in any
.astroor.jsonfile - Zero instances of "7-day free trial" remain anywhere
- All EN CTA buttons read "Get Started Free"
- All AR CTA buttons read "ابدأ مجاناً"
- Trial badge on pricing page reads "250 free messages - No credit card" / "٢٥٠ رسالة مجانية - بدون بطاقة ائتمان"
- Meta descriptions updated to reference "250 free messages" instead of "free trial"
-
npm run buildsucceeds with 0 errors - Verify both EN and AR pricing pages display updated badge text
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
- Free plan appears as the first (leftmost) card in pricing grid
- Free plan shows "Free" / "مجاني" instead of "SAR 0" or "USD 0"
- No currency symbol or period label shown for Free plan
- Free plan CTA button links to
/register(no plan param) - 5-column grid renders correctly on xl screens (>1280px)
- 3-column on lg, 2-column on md, 1-column on mobile
- Currency and duration toggles have no effect on Free plan display
- Growth plan still shows "Most Popular" badge
-
npm run buildsucceeds with 0 errors - Both EN and AR pricing pages render all 5 plans correctly
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
- Tawk.to widget appears on all pages when env vars are set
- Widget does NOT appear when env vars are missing (dev mode safe)
- English pages load the EN widget ID, Arabic pages load the AR widget ID
- No CSP violations in browser console on any page
- Widget does not block page rendering (script is async)
-
npm run buildsucceeds with 0 errors - Widget is accessible (keyboard navigable)
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
- VideoSection renders poster image with play button overlay
- Clicking play loads YouTube iframe with autoplay
- Keyboard accessible (Enter/Space triggers play)
- 16:9 aspect ratio maintained at all viewport sizes
- Uses
youtube-nocookie.com(privacy-enhanced mode) - Iframe only loads on user interaction (no initial YouTube request)
- No CSP violations for YouTube embed
- Component works in both LTR and RTL layouts
-
npm run buildsucceeds with 0 errors - Replace
REPLACE_WITH_ACTUAL_VIDEO_IDwith real video before deploying
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
- In GTM, go to Tags > Google Tag (GA4 Configuration)
- Under Configuration Settings, add parameter:
- Parameter:
linker - Value:
{ "domains": ["urwhats.com", "app.urwhats.com"] }
- Parameter:
- Or use the Cross Domain feature in GA4 Admin:
- Go to GA4 Admin > Data Streams > Web > Configure Tag Settings > Configure Your Domains
- Add:
urwhats.comandapp.urwhats.com
- This ensures the
_glparameter 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
- Clicking any plan CTA fires
plan_selectedwith correct plan_id and plan_name - Clicking language switcher fires
language_switchwith new_language value -
window.dataLayeris initialized before push (defensive|| []) - Events visible in GTM Preview mode
- No JavaScript errors in console on any page
-
npm run buildsucceeds with 0 errors - Cross-domain linker documented for GTM admin
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
- Blog posts render
og:type="article"(not"website") -
article:published_timemeta tag present with ISO date -
article:authormeta tag present -
article:tagmeta tags present for each tag - JSON-LD contains
Articleschema with correctheadline,datePublished,author - JSON-LD for blog posts does NOT include
SoftwareApplicationorServiceschemas - Non-blog pages still render
og:type="website"(no regression) - Rich Results Test passes for blog post pages
-
npm run buildsucceeds with 0 errors
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:
- Primary:
"250 free messages to get started. No credit card required." - Secondary:
"Join hundreds of Saudi businesses using urWhats." - Short:
"No credit card required."
Standard AR supporting text options:
- Primary:
"٢٥٠ رسالة مجانية للبدء. بدون بطاقة ائتمان." - Secondary:
"انضم لمئات الشركات السعودية التي تستخدم urWhats." - Short:
"بدون بطاقة ائتمان."
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
- Homepage CTA section shows updated title and subtitle
- Pricing page CTA shows "250 free messages" messaging
- All CTASection subtitles are consistent and use one of the standard patterns
- No references to "14-day" trial remain
- Both EN and AR versions match in meaning
-
npm run buildsucceeds with 0 errors
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:
- Log into https://dashboard.tawk.to
- Go to Settings > Chat Widget
- The embed code contains:
https://embed.tawk.to/{PROPERTY_ID}/{WIDGET_ID} - Create two widgets: one for English, one for Arabic
- Set the property ID and both widget IDs in Cloudflare Pages env vars
Acceptance Criteria - Task 9
-
.env.examplecontains all 7 env variables with comments -
src/env.d.tsdeclares all env vars with correct types - All Tawk.to env vars are marked optional (
?) - No actual secrets/keys appear in
.env.example(only empty values) - Cloudflare Pages setup instructions are documented above
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:
- Batch 1 (parallel): Tasks 1, 2, 3, 4, 5, 6
- 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 |